From b00915841d2f58c19420961f88506b683a7c797d Mon Sep 17 00:00:00 2001 From: Dave Sargent Date: Sun, 17 May 2026 09:56:39 -0700 Subject: [PATCH] feat(landing): capability-driven default landing route per active box (#112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RootRedirect's fallback chain hard-coded /haproxy as the dashboard default for any installation with at least one enabled box. On a TrueNAS-style deployment whose agent runs in a distroless container without HAProxy (Mjolnir), users would log in and be dropped on an empty /haproxy page that 503-storms on the per-box stat tiles. Make the landing route per-box and capability-aware: - /haproxy when the active box's agent reports haproxy available (preserves the historical landing for HAProxy-fronted deployments) - /metrics when haproxy isn't available but metrics is — the next-most-useful single-pane-of-glass for a host-only agent - /bx as the universal fallback so an active box with no advertised gears still has a place to land Fail-open to /haproxy when capabilities can't be fetched (agent down, no API key, older agent that pre-dates probing) so a transient outage doesn't change the dashboard's behavior. Per-user / system landing-path overrides (login.go::resolvePostLoginPath) keep priority over this — a user who has explicitly set their landing URL still gets it. Four tests exercise the new helper against a real httptest.Server returning capability responses: haproxy-prefers-haproxy, no-haproxy-falls-to-metrics, host-only-falls-to-bx, and agent-down-fails-open-to-haproxy. Phase 2 slice of #112. Stacked on fix/issue-112-haproxy-empty-state (#118). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../framework/handler/default_landing_test.go | 145 ++++++++++++++++++ gearbox/internal/framework/handler/login.go | 45 +++++- 2 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 gearbox/internal/framework/handler/default_landing_test.go diff --git a/gearbox/internal/framework/handler/default_landing_test.go b/gearbox/internal/framework/handler/default_landing_test.go new file mode 100644 index 0000000..8872125 --- /dev/null +++ b/gearbox/internal/framework/handler/default_landing_test.go @@ -0,0 +1,145 @@ +package handler + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/sarg3nt/gearbox/internal/framework/agent" + "github.com/sarg3nt/gearbox/internal/framework/models" +) + +// fakeCapabilitiesServer serves the supplied CapabilitiesResponse on the +// agent's well-known capabilities path, so defaultLandingForActiveBox +// goes through the production cache fetch path rather than a stubbed +// cache. Returns an httptest.Server the caller is responsible for closing. +func fakeCapabilitiesServer(t *testing.T, resp agent.CapabilitiesResponse) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/system/capabilities" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Fatalf("encode capabilities: %v", err) + } + })) +} + +// newLandingTestHandler builds the minimum Handler state +// defaultLandingForActiveBox needs: a logger, a static `servers` slice +// (so getServerConfig resolves without DB), and a fresh CapabilitiesCache. +func newLandingTestHandler(t *testing.T, boxID, agentURL string) *Handler { + t.Helper() + return &Handler{ + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + capabilities: agent.NewCapabilitiesCache(5*time.Minute, 2*time.Second), + servers: []models.BoxConfig{ + {ID: boxID, AgentURL: agentURL, APIKey: "test-key"}, + }, + } +} + +// requestWithCookie returns an *http.Request with the gearbox_active_box +// cookie set to boxID, so resolveBoxIDFromRequest picks up that box +// without needing a `?server=` URL param. +func requestWithCookie(boxID string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: activeBoxCookieName, Value: boxID}) + return r +} + +// TestDefaultLandingForActiveBox_PrefersHAProxy: when the active box's +// agent advertises haproxy, the landing route is /haproxy regardless of +// what else is available. Preserves the historical landing for any +// HAProxy-fronted deployment. +func TestDefaultLandingForActiveBox_PrefersHAProxy(t *testing.T) { + srv := fakeCapabilitiesServer(t, agent.CapabilitiesResponse{ + Gears: map[string]agent.CapabilityEntry{ + "haproxy": {Status: "available"}, + "metrics": {Status: "available"}, + }, + }) + t.Cleanup(srv.Close) + + h := newLandingTestHandler(t, "light-hugger", srv.URL) + // resolveBoxIDFromRequest needs the box to be in the enabled list to + // honor the cookie. Without a DB the helper falls back to + // getDefaultServerID() which returns "" — but the cookie path requires + // the box to be in getEnabledServers. We sidestep that by passing the + // box through `?server=` which always wins. + r := httptest.NewRequest(http.MethodGet, "/?server=light-hugger", nil) + if got := h.defaultLandingForActiveBox(r); got != "/haproxy" { + t.Errorf("haproxy-capable box landed on %q, want /haproxy", got) + } +} + +// TestDefaultLandingForActiveBox_FallsBackToMetrics: an active box with +// no haproxy gear but with metrics available lands on /metrics. This is +// the Mjolnir scenario — a TrueNAS container agent. +func TestDefaultLandingForActiveBox_FallsBackToMetrics(t *testing.T) { + srv := fakeCapabilitiesServer(t, agent.CapabilitiesResponse{ + Gears: map[string]agent.CapabilityEntry{ + "haproxy": {Status: "inaccessible"}, + "metrics": {Status: "available"}, + "host": {Status: "available"}, + "access-log": {Status: "available"}, + }, + }) + t.Cleanup(srv.Close) + + h := newLandingTestHandler(t, "mjolnir", srv.URL) + r := httptest.NewRequest(http.MethodGet, "/?server=mjolnir", nil) + if got := h.defaultLandingForActiveBox(r); got != "/metrics" { + t.Errorf("metrics-only box landed on %q, want /metrics", got) + } +} + +// TestDefaultLandingForActiveBox_FallsBackToBx: an active box with +// neither haproxy nor metrics still gets a sensible landing — the Bx +// fleet view is universally available. +func TestDefaultLandingForActiveBox_FallsBackToBx(t *testing.T) { + srv := fakeCapabilitiesServer(t, agent.CapabilitiesResponse{ + Gears: map[string]agent.CapabilityEntry{ + "haproxy": {Status: "inaccessible"}, + "metrics": {Status: "inaccessible"}, + "host": {Status: "available"}, + }, + }) + t.Cleanup(srv.Close) + + h := newLandingTestHandler(t, "minimal", srv.URL) + r := httptest.NewRequest(http.MethodGet, "/?server=minimal", nil) + if got := h.defaultLandingForActiveBox(r); got != "/bx" { + t.Errorf("minimal box landed on %q, want /bx", got) + } +} + +// TestDefaultLandingForActiveBox_FailOpenOnAgentDown: when capabilities +// can't be fetched (agent unreachable), the landing route falls back to +// /haproxy to match historical behavior. Same contract as the rest of +// the capability-driven helpers added in #112 — a flaky agent doesn't +// change the dashboard's behavior. +func TestDefaultLandingForActiveBox_FailOpenOnAgentDown(t *testing.T) { + closed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + closed.Close() // close immediately so the URL refuses connections + + h := newLandingTestHandler(t, "downbox", closed.URL) + r := httptest.NewRequest(http.MethodGet, "/?server=downbox", nil) + if got := h.defaultLandingForActiveBox(r); got != "/haproxy" { + t.Errorf("unreachable agent landed on %q, want /haproxy (fail-open)", got) + } +} + +// The "no resolvable box" case isn't covered here — RootRedirect's +// CountEnabledBoxes check fires before defaultLandingForActiveBox is +// called, so a no-boxes install never reaches this helper in +// production. Testing it would require stubbing the database layer, +// which isn't worth the setup cost for an unreachable branch. diff --git a/gearbox/internal/framework/handler/login.go b/gearbox/internal/framework/handler/login.go index 53261b0..3df2e77 100644 --- a/gearbox/internal/framework/handler/login.go +++ b/gearbox/internal/framework/handler/login.go @@ -170,7 +170,14 @@ func (h *Handler) LoginPost(w http.ResponseWriter, r *http.Request) { // default-landing-path (per-user → system → fallback). When nothing is // configured the fallback chain is: // -// 1. Any box enabled → /haproxy +// 1. Any box enabled → capability-driven landing for the +// active box (see +// defaultLandingForActiveBox). On a +// HAProxy-capable box that's /haproxy +// exactly as before; on a Mjolnir-style +// TrueNAS container agent (no haproxy) +// the user lands on /metrics or /bx +// instead of an empty HAProxy page. // 2. Any system gear enabled → /home (today the only system gear) // 3. Otherwise → /welcome (first-run onboarding, issue #49) func (h *Handler) RootRedirect(w http.ResponseWriter, r *http.Request) { @@ -186,7 +193,7 @@ func (h *Handler) RootRedirect(w http.ResponseWriter, r *http.Request) { } if count, err := h.db.CountEnabledBoxes(); err == nil && count > 0 { - http.Redirect(w, r, "/haproxy", http.StatusSeeOther) + http.Redirect(w, r, h.defaultLandingForActiveBox(r), http.StatusSeeOther) return } if homeGear, err := h.db.GetGear(database.SystemServerID, database.GearHome); err == nil && homeGear != nil && homeGear.Enabled { @@ -196,6 +203,40 @@ func (h *Handler) RootRedirect(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/welcome", http.StatusSeeOther) } +// defaultLandingForActiveBox returns the dashboard landing path for the +// box currently active in the header pill, derived from that box's +// agent probe table. Preference order: +// +// - /haproxy when the active box's agent reports the haproxy gear +// available — preserves the historical landing for any +// HAProxy-fronted deployment. +// - /metrics when haproxy isn't available but the metrics gear is — +// the next-most-useful single-pane-of-glass for a host-only agent. +// - /bx as the universal fallback so an active box with no advertised +// gears still has a place to land. +// +// Fail-open to /haproxy when capabilities can't be fetched (agent down, +// no API key, older agent that pre-dates probing). This matches the +// dashboard's broader fail-open posture from issue #112: a transient +// agent outage shouldn't change where the dashboard lands the user. +func (h *Handler) defaultLandingForActiveBox(r *http.Request) string { + boxID := h.resolveBoxIDFromRequest(r) + if boxID == "" { + return "/haproxy" + } + caps, ok := h.getBoxCapabilities(boxID) + if !ok { + return "/haproxy" + } + if caps.IsAvailable("haproxy") { + return "/haproxy" + } + if caps.IsAvailable("metrics") { + return "/metrics" + } + return "/bx" +} + // Logout handles user logout. func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { if err := h.authManager.Logout(w, r); err != nil {