Skip to content

Commit 0836abe

Browse files
sarg3ntclaude
andauthored
feat(landing): capability-driven default landing route per active box (#112) (#133)
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) <noreply@anthropic.com>
1 parent 812d2f2 commit 0836abe

2 files changed

Lines changed: 188 additions & 2 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package handler
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"log/slog"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
"time"
11+
12+
"github.com/sarg3nt/gearbox/internal/framework/agent"
13+
"github.com/sarg3nt/gearbox/internal/framework/models"
14+
)
15+
16+
// fakeCapabilitiesServer serves the supplied CapabilitiesResponse on the
17+
// agent's well-known capabilities path, so defaultLandingForActiveBox
18+
// goes through the production cache fetch path rather than a stubbed
19+
// cache. Returns an httptest.Server the caller is responsible for closing.
20+
func fakeCapabilitiesServer(t *testing.T, resp agent.CapabilitiesResponse) *httptest.Server {
21+
t.Helper()
22+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23+
if r.URL.Path != "/api/v1/system/capabilities" {
24+
http.NotFound(w, r)
25+
return
26+
}
27+
w.Header().Set("Content-Type", "application/json")
28+
if err := json.NewEncoder(w).Encode(resp); err != nil {
29+
t.Fatalf("encode capabilities: %v", err)
30+
}
31+
}))
32+
}
33+
34+
// newLandingTestHandler builds the minimum Handler state
35+
// defaultLandingForActiveBox needs: a logger, a static `servers` slice
36+
// (so getServerConfig resolves without DB), and a fresh CapabilitiesCache.
37+
func newLandingTestHandler(t *testing.T, boxID, agentURL string) *Handler {
38+
t.Helper()
39+
return &Handler{
40+
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
41+
capabilities: agent.NewCapabilitiesCache(5*time.Minute, 2*time.Second),
42+
servers: []models.BoxConfig{
43+
{ID: boxID, AgentURL: agentURL, APIKey: "test-key"},
44+
},
45+
}
46+
}
47+
48+
// requestWithCookie returns an *http.Request with the gearbox_active_box
49+
// cookie set to boxID, so resolveBoxIDFromRequest picks up that box
50+
// without needing a `?server=` URL param.
51+
func requestWithCookie(boxID string) *http.Request {
52+
r := httptest.NewRequest(http.MethodGet, "/", nil)
53+
r.AddCookie(&http.Cookie{Name: activeBoxCookieName, Value: boxID})
54+
return r
55+
}
56+
57+
// TestDefaultLandingForActiveBox_PrefersHAProxy: when the active box's
58+
// agent advertises haproxy, the landing route is /haproxy regardless of
59+
// what else is available. Preserves the historical landing for any
60+
// HAProxy-fronted deployment.
61+
func TestDefaultLandingForActiveBox_PrefersHAProxy(t *testing.T) {
62+
srv := fakeCapabilitiesServer(t, agent.CapabilitiesResponse{
63+
Gears: map[string]agent.CapabilityEntry{
64+
"haproxy": {Status: "available"},
65+
"metrics": {Status: "available"},
66+
},
67+
})
68+
t.Cleanup(srv.Close)
69+
70+
h := newLandingTestHandler(t, "light-hugger", srv.URL)
71+
// resolveBoxIDFromRequest needs the box to be in the enabled list to
72+
// honor the cookie. Without a DB the helper falls back to
73+
// getDefaultServerID() which returns "" — but the cookie path requires
74+
// the box to be in getEnabledServers. We sidestep that by passing the
75+
// box through `?server=` which always wins.
76+
r := httptest.NewRequest(http.MethodGet, "/?server=light-hugger", nil)
77+
if got := h.defaultLandingForActiveBox(r); got != "/haproxy" {
78+
t.Errorf("haproxy-capable box landed on %q, want /haproxy", got)
79+
}
80+
}
81+
82+
// TestDefaultLandingForActiveBox_FallsBackToMetrics: an active box with
83+
// no haproxy gear but with metrics available lands on /metrics. This is
84+
// the Mjolnir scenario — a TrueNAS container agent.
85+
func TestDefaultLandingForActiveBox_FallsBackToMetrics(t *testing.T) {
86+
srv := fakeCapabilitiesServer(t, agent.CapabilitiesResponse{
87+
Gears: map[string]agent.CapabilityEntry{
88+
"haproxy": {Status: "inaccessible"},
89+
"metrics": {Status: "available"},
90+
"host": {Status: "available"},
91+
"access-log": {Status: "available"},
92+
},
93+
})
94+
t.Cleanup(srv.Close)
95+
96+
h := newLandingTestHandler(t, "mjolnir", srv.URL)
97+
r := httptest.NewRequest(http.MethodGet, "/?server=mjolnir", nil)
98+
if got := h.defaultLandingForActiveBox(r); got != "/metrics" {
99+
t.Errorf("metrics-only box landed on %q, want /metrics", got)
100+
}
101+
}
102+
103+
// TestDefaultLandingForActiveBox_FallsBackToBx: an active box with
104+
// neither haproxy nor metrics still gets a sensible landing — the Bx
105+
// fleet view is universally available.
106+
func TestDefaultLandingForActiveBox_FallsBackToBx(t *testing.T) {
107+
srv := fakeCapabilitiesServer(t, agent.CapabilitiesResponse{
108+
Gears: map[string]agent.CapabilityEntry{
109+
"haproxy": {Status: "inaccessible"},
110+
"metrics": {Status: "inaccessible"},
111+
"host": {Status: "available"},
112+
},
113+
})
114+
t.Cleanup(srv.Close)
115+
116+
h := newLandingTestHandler(t, "minimal", srv.URL)
117+
r := httptest.NewRequest(http.MethodGet, "/?server=minimal", nil)
118+
if got := h.defaultLandingForActiveBox(r); got != "/bx" {
119+
t.Errorf("minimal box landed on %q, want /bx", got)
120+
}
121+
}
122+
123+
// TestDefaultLandingForActiveBox_FailOpenOnAgentDown: when capabilities
124+
// can't be fetched (agent unreachable), the landing route falls back to
125+
// /haproxy to match historical behavior. Same contract as the rest of
126+
// the capability-driven helpers added in #112 — a flaky agent doesn't
127+
// change the dashboard's behavior.
128+
func TestDefaultLandingForActiveBox_FailOpenOnAgentDown(t *testing.T) {
129+
closed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
130+
http.Error(w, "boom", http.StatusInternalServerError)
131+
}))
132+
closed.Close() // close immediately so the URL refuses connections
133+
134+
h := newLandingTestHandler(t, "downbox", closed.URL)
135+
r := httptest.NewRequest(http.MethodGet, "/?server=downbox", nil)
136+
if got := h.defaultLandingForActiveBox(r); got != "/haproxy" {
137+
t.Errorf("unreachable agent landed on %q, want /haproxy (fail-open)", got)
138+
}
139+
}
140+
141+
// The "no resolvable box" case isn't covered here — RootRedirect's
142+
// CountEnabledBoxes check fires before defaultLandingForActiveBox is
143+
// called, so a no-boxes install never reaches this helper in
144+
// production. Testing it would require stubbing the database layer,
145+
// which isn't worth the setup cost for an unreachable branch.

gearbox/internal/framework/handler/login.go

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,14 @@ func (h *Handler) LoginPost(w http.ResponseWriter, r *http.Request) {
170170
// default-landing-path (per-user → system → fallback). When nothing is
171171
// configured the fallback chain is:
172172
//
173-
// 1. Any box enabled → /haproxy
173+
// 1. Any box enabled → capability-driven landing for the
174+
// active box (see
175+
// defaultLandingForActiveBox). On a
176+
// HAProxy-capable box that's /haproxy
177+
// exactly as before; on a Mjolnir-style
178+
// TrueNAS container agent (no haproxy)
179+
// the user lands on /metrics or /bx
180+
// instead of an empty HAProxy page.
174181
// 2. Any system gear enabled → /home (today the only system gear)
175182
// 3. Otherwise → /welcome (first-run onboarding, issue #49)
176183
func (h *Handler) RootRedirect(w http.ResponseWriter, r *http.Request) {
@@ -186,7 +193,7 @@ func (h *Handler) RootRedirect(w http.ResponseWriter, r *http.Request) {
186193
}
187194

188195
if count, err := h.db.CountEnabledBoxes(); err == nil && count > 0 {
189-
http.Redirect(w, r, "/haproxy", http.StatusSeeOther)
196+
http.Redirect(w, r, h.defaultLandingForActiveBox(r), http.StatusSeeOther)
190197
return
191198
}
192199
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) {
196203
http.Redirect(w, r, "/welcome", http.StatusSeeOther)
197204
}
198205

206+
// defaultLandingForActiveBox returns the dashboard landing path for the
207+
// box currently active in the header pill, derived from that box's
208+
// agent probe table. Preference order:
209+
//
210+
// - /haproxy when the active box's agent reports the haproxy gear
211+
// available — preserves the historical landing for any
212+
// HAProxy-fronted deployment.
213+
// - /metrics when haproxy isn't available but the metrics gear is —
214+
// the next-most-useful single-pane-of-glass for a host-only agent.
215+
// - /bx as the universal fallback so an active box with no advertised
216+
// gears still has a place to land.
217+
//
218+
// Fail-open to /haproxy when capabilities can't be fetched (agent down,
219+
// no API key, older agent that pre-dates probing). This matches the
220+
// dashboard's broader fail-open posture from issue #112: a transient
221+
// agent outage shouldn't change where the dashboard lands the user.
222+
func (h *Handler) defaultLandingForActiveBox(r *http.Request) string {
223+
boxID := h.resolveBoxIDFromRequest(r)
224+
if boxID == "" {
225+
return "/haproxy"
226+
}
227+
caps, ok := h.getBoxCapabilities(boxID)
228+
if !ok {
229+
return "/haproxy"
230+
}
231+
if caps.IsAvailable("haproxy") {
232+
return "/haproxy"
233+
}
234+
if caps.IsAvailable("metrics") {
235+
return "/metrics"
236+
}
237+
return "/bx"
238+
}
239+
199240
// Logout handles user logout.
200241
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
201242
if err := h.authManager.Logout(w, r); err != nil {

0 commit comments

Comments
 (0)