Skip to content

Commit d0ce17d

Browse files
sarg3ntclaude
andcommitted
fix(sidebar): address Copilot review on PR 119 (#112)
Two findings: 1. Services dashboard gear had no entry in dashboardGearToAgentGear, so the sidebar filter passed it through unconditionally — the exact symptom this PR was supposed to fix. Map it to "metrics" (the agent gear that registers /api/v1/services per gearbox-agent/internal/gears/metrics/plugin.go), and document that this is imprecise: the agent's metrics gear can be Available on a host where systemd isn't introspectable from the container, so a tighter gate (an explicit `services` capability that advertises systemd reachability) is part of the Phase 2 extension in #112. 2. No regression test covered the middleware path or the filter's fail-open contract. Add three focused tests on filterGearsByAgentCapabilities driven by a real httptest.Server returning a CapabilitiesResponse: - Mjolnir-shaped probe table → only available gears + dashboard- only gears (alerts) survive. - Agent unreachable → fail open, full list passes through. - Older agent that doesn't surface a gear name at all → that gear is kept (forward-compat, mirrors the #116 fix). The tests share a slim Handler fixture (logger, capabilities cache, static servers slice) so they exercise the production fetch path through CapabilitiesCache without needing a DB or auth manager. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d7676b3 commit d0ce17d

2 files changed

Lines changed: 193 additions & 3 deletions

File tree

gearbox/internal/framework/handler/gears.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,29 @@ func (h *Handler) GearsPage(w http.ResponseWriter, r *http.Request) {
8282

8383
// dashboardGearToAgentGear maps a dashboard gear name to the agent gear
8484
// whose probe verdict gates its visibility. Dashboard gears not in this map
85-
// have no agent counterpart and are always shown (services & alerts are
86-
// always-on dashboard concepts; certbot piggy-backs on certificates;
87-
// system gears like home don't probe any host capability).
85+
// have no agent counterpart and are always shown:
86+
//
87+
// - alerts is a dashboard-only concept (rules, notifiers, history) that
88+
// evaluates signals already surfaced by the gated gears above. Hiding
89+
// it would orphan still-firing alerts when their source gear flickers,
90+
// so it stays unconditional.
91+
// - certbot piggy-backs on certificates.
92+
// - system gears like home don't probe any host capability.
93+
//
94+
// The services dashboard gear maps to the agent's `metrics` gear because
95+
// /api/v1/services is registered by the metrics plugin (see
96+
// gearbox-agent/internal/gears/metrics/plugin.go); this is imprecise —
97+
// the agent's metrics gear can be Available on a host where systemd
98+
// isn't introspectable from the container, so the services entry can
99+
// still surface on a box where it'll come up empty. A tighter gate
100+
// (e.g. an explicit `services` capability advertising systemd
101+
// reachability) is part of the Phase 2 extension in issue #112.
88102
var dashboardGearToAgentGear = map[string]string{
89103
database.GearHAProxy: "haproxy",
90104
database.GearLogs: "logs",
91105
database.GearCertificates: "certificates",
92106
database.GearMetrics: "metrics",
107+
database.GearServices: "metrics",
93108
database.GearTraffic: "traffic",
94109
database.GearOSUpdates: "updates",
95110
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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/database"
14+
"github.com/sarg3nt/gearbox/internal/framework/models"
15+
)
16+
17+
// newCapabilitiesHandler returns an http.Handler that serves the supplied
18+
// CapabilitiesResponse on /api/v1/system/capabilities. Used by the tests
19+
// below to drive the filter through a real httptest.Server so the
20+
// production CapabilitiesCache fetch path (not just a stubbed cache) is
21+
// exercised.
22+
func newCapabilitiesHandler(t *testing.T, resp agent.CapabilitiesResponse) http.Handler {
23+
t.Helper()
24+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25+
if r.URL.Path != "/api/v1/system/capabilities" {
26+
http.NotFound(w, r)
27+
return
28+
}
29+
w.Header().Set("Content-Type", "application/json")
30+
if err := json.NewEncoder(w).Encode(resp); err != nil {
31+
t.Fatalf("encode capabilities: %v", err)
32+
}
33+
})
34+
}
35+
36+
// newCapabilitiesTestHandler stitches together the minimum Handler state
37+
// the filter needs: a logger, a static `servers` slice (so getServerConfig
38+
// resolves without DB), and a fresh CapabilitiesCache.
39+
func newCapabilitiesTestHandler(t *testing.T, boxID, agentURL string) *Handler {
40+
t.Helper()
41+
return &Handler{
42+
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
43+
capabilities: agent.NewCapabilitiesCache(5*time.Minute, 2*time.Second),
44+
servers: []models.BoxConfig{
45+
{ID: boxID, AgentURL: agentURL, APIKey: "test-key"},
46+
},
47+
}
48+
}
49+
50+
func gearsByName(in []database.Gear) map[string]bool {
51+
out := make(map[string]bool, len(in))
52+
for _, g := range in {
53+
out[g.Name] = true
54+
}
55+
return out
56+
}
57+
58+
// TestFilterGearsByAgentCapabilities_HidesUnavailable mirrors the
59+
// production Mjolnir agent's probe table: access-log/host/metrics
60+
// available, every other gear unavailable. The filter must keep only
61+
// dashboard gears whose agent counterpart is available, drop gears
62+
// whose counterpart is reported unavailable, and leave dashboard-only
63+
// gears (alerts, home, bx) untouched. Guards both the Gears settings
64+
// page and the sidebar middleware path (issue #112).
65+
func TestFilterGearsByAgentCapabilities_HidesUnavailable(t *testing.T) {
66+
srv := httptest.NewServer(newCapabilitiesHandler(t, agent.CapabilitiesResponse{
67+
Gears: map[string]agent.CapabilityEntry{
68+
"access-log": {Status: "available"},
69+
"host": {Status: "available"},
70+
"metrics": {Status: "available"},
71+
"apache": {Status: "not_installed"},
72+
"caddy": {Status: "not_installed"},
73+
"certificates": {Status: "not_installed"},
74+
"docker": {Status: "not_installed"},
75+
"haproxy": {Status: "inaccessible"},
76+
"logs": {Status: "inaccessible"},
77+
"nginx": {Status: "not_installed"},
78+
"security": {Status: "not_installed"},
79+
"traefik": {Status: "not_installed"},
80+
"traffic": {Status: "inaccessible"},
81+
"updates": {Status: "not_installed"},
82+
},
83+
}))
84+
t.Cleanup(srv.Close)
85+
86+
h := newCapabilitiesTestHandler(t, "mjolnir", srv.URL)
87+
dashboardGears := []database.Gear{
88+
{Name: database.GearHAProxy},
89+
{Name: database.GearLogs},
90+
{Name: database.GearMetrics},
91+
{Name: database.GearServices},
92+
{Name: database.GearCertificates},
93+
{Name: database.GearTraffic},
94+
{Name: database.GearOSUpdates},
95+
{Name: database.GearAlerts},
96+
}
97+
98+
got := gearsByName(h.filterGearsByAgentCapabilities("mjolnir", dashboardGears))
99+
100+
want := map[string]bool{
101+
// metrics is available → keep
102+
database.GearMetrics: true,
103+
// services maps to "metrics" (which is available) → keep
104+
database.GearServices: true,
105+
// alerts has no agent counterpart → keep (always-on)
106+
database.GearAlerts: true,
107+
}
108+
// haproxy / logs / traffic mapped to inaccessible agent gears → drop.
109+
// certificates / updates mapped to not_installed → drop.
110+
for name := range gearsByName(dashboardGears) {
111+
if want[name] {
112+
if !got[name] {
113+
t.Errorf("filter dropped %q but it should remain", name)
114+
}
115+
continue
116+
}
117+
if got[name] {
118+
t.Errorf("filter kept %q but it should be hidden (agent gear unavailable)", name)
119+
}
120+
}
121+
}
122+
123+
// TestFilterGearsByAgentCapabilities_FailsOpenOnAgentDown verifies that
124+
// when capabilities can't be fetched, the filter returns the full list
125+
// unchanged. Same contract as the Gears settings page — a flaky agent
126+
// must not collapse the sidebar / settings page (issue #112).
127+
func TestFilterGearsByAgentCapabilities_FailsOpenOnAgentDown(t *testing.T) {
128+
// AgentURL points at a closed port so the fetch errors. APIKey isn't
129+
// empty so the dashboard considers the box agent-API-backed.
130+
closed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
131+
http.Error(w, "boom", http.StatusInternalServerError)
132+
}))
133+
closed.Close()
134+
135+
h := newCapabilitiesTestHandler(t, "downbox", closed.URL)
136+
gears := []database.Gear{
137+
{Name: database.GearHAProxy},
138+
{Name: database.GearLogs},
139+
{Name: database.GearMetrics},
140+
}
141+
got := h.filterGearsByAgentCapabilities("downbox", gears)
142+
if len(got) != len(gears) {
143+
t.Errorf("fail-open broken: filter returned %d gears, expected all %d", len(got), len(gears))
144+
}
145+
}
146+
147+
// TestFilterGearsByAgentCapabilities_FailsOpenOnUnknownGear verifies
148+
// that an agent that doesn't surface a particular gear name at all
149+
// (older agent that pre-dates the gear) leaves the dashboard gear in
150+
// the result — distinguishing "not reported" from "reported as
151+
// unavailable" is the same fix #116 added on the Logs source picker.
152+
func TestFilterGearsByAgentCapabilities_FailsOpenOnUnknownGear(t *testing.T) {
153+
srv := httptest.NewServer(newCapabilitiesHandler(t, agent.CapabilitiesResponse{
154+
// Older agent: surfaces only haproxy; doesn't know about logs/metrics yet.
155+
Gears: map[string]agent.CapabilityEntry{
156+
"haproxy": {Status: "available"},
157+
},
158+
}))
159+
t.Cleanup(srv.Close)
160+
161+
h := newCapabilitiesTestHandler(t, "oldbox", srv.URL)
162+
gears := []database.Gear{
163+
{Name: database.GearHAProxy},
164+
{Name: database.GearLogs},
165+
{Name: database.GearMetrics},
166+
}
167+
got := gearsByName(h.filterGearsByAgentCapabilities("oldbox", gears))
168+
// All three should be present: haproxy because available, logs and
169+
// metrics because the agent didn't report them (forward-compat).
170+
for _, name := range []string{database.GearHAProxy, database.GearLogs, database.GearMetrics} {
171+
if !got[name] {
172+
t.Errorf("forward-compat fail-open broken: %q dropped", name)
173+
}
174+
}
175+
}

0 commit comments

Comments
 (0)