Skip to content

Commit 2a3fc24

Browse files
authored
feat(#91): metrics gear — source-aware, no-HAProxy mode (phases 0-2) (#93)
* feat(#91): metrics gear — source-aware, no-HAProxy mode (phases 0-2) Lay the foundation for a source-agnostic Metrics gear by introducing the MetricSource concept end-to-end. The page now declares which collector produces each number ("HAProxy: Sessions & Requests", "Host: CPU Load", …) and gracefully degrades on boxes without HAProxy. Phase 0 — Capability manifest plumbing on the dashboard: - New per-box CapabilitiesCache (5-min TTL, 3s fetch timeout, negative caching) in the agent client package; tested with httptest. - Handler-level BoxCapabilities accessor + cache instance; reconnect events (server.connected) invalidate so a restarted agent's new probe table is reflected immediately. - New GET /api/{boxID}/capabilities endpoint surfaces the cached manifest to the dashboard's auth-scoped frontend. - filterGearsByAgentCapabilities (issue #71) refactored onto the cache — removes one synchronous agent call per Gears-page render. Phase 1 — Source attribution in the UI: - Chart card titles carry source-prefix badges (HAProxy / Host) so the reader can tell which collector produced the metric at a glance. - KPI cards render a small source-badge in the upper-right corner; server emits source+source_label on every card. - "Error Insights" → "HAProxy Error Insights" with explicit copy. Phase 2 — Graceful no-HAProxy mode: - /api/{boxID}/metrics/summary checks the haproxy capability and only emits HAProxy KPI cards when the gear is available; host KPIs (memory, disk, load 1m) are always emitted from system_metrics_history. - Frontend hides HAProxy-tagged chart cards and the Error Insights panel when capabilities lack haproxy; shows an empty-state banner. - Fail-open everywhere — flaky capabilities don't lock the user out. CPU%, uptime, and failed-systemd KPIs aren't persisted yet; they'll follow when the collector starts saving them. Refs: docs/research/metrics-source-agnostic.md * feat: add .local.config to .gitignore * fix(#91): address Copilot review findings on PR #93 - api_capabilities.go: gate /api/{boxID}/capabilities on ComponentMetrics + PermissionView. The manifest enumerates installed services on the host, enough for fingerprinting in multi-tenant deploys — a user without metrics:view shouldn't enumerate the software inventory. Mirrors APIMetricsSummaryHandler's gate. - history.templ: no-HAProxy banner now picks copy from the actual capability entry — not_installed / inaccessible / disabled each get distinct guidance, and the agent's `reason` is surfaced verbatim. Previous copy ("Install HAProxy") pointed at the wrong fix when the binary was present but stats unreachable. - capabilities_cache.go: cache key is now (boxID, agentURL) so an operator editing a box's Agent URL gets fresh capabilities on the next render rather than stale data until the 5-min TTL expires. Invalidate() drops every entry for the boxID regardless of URL. - haproxy_config.go: invalidate the capabilities cache on box update and delete so Agent URL / API key edits take effect immediately. - api_metrics_insights.go: capability gating uses the sourceHAProxy constant instead of a duplicate "haproxy" literal — prevents future drift between KPI source IDs and capability lookups. New tests: - TestCapabilitiesCacheDifferentAgentURLBypassesCache — same boxID with different agent URLs returns each agent's actual verdict, not stale. - TestCapabilitiesCacheInvalidateDropsAllAgentURLsForBox — Invalidate drops every entry for a boxID across all URLs it was fetched against.
1 parent 502bd46 commit 2a3fc24

12 files changed

Lines changed: 1040 additions & 52 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ gearbox-agent/data/
1212
# Environment
1313
gearbox/.env
1414
gearbox-agent/.env
15+
.local.config
1516
*.local.json
1617
.mcp.json
1718

gearbox/cmd/server/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,11 @@ func main() {
688688
r.Get("/{boxID}/metrics/backend/{backendName}/details", h.APIMetricsBackendDetailsHandler)
689689
r.Get("/{boxID}/metrics/log-errors", h.APIMetricsLogErrorsHandler)
690690

691+
// Per-box capability manifest — exposes which agent gears probed
692+
// available so the metrics gear (and future source-aware UI) can
693+
// hide cards/KPIs that don't apply to this host.
694+
r.Get("/{boxID}/capabilities", h.APIBoxCapabilitiesHandler)
695+
691696
// Disabled entities management
692697
r.Get("/{boxID}/disabled-entities", h.APIDisabledEntitiesHandler)
693698
r.Post("/{boxID}/disable-entity", h.APIDisableEntityHandler)
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package agent
2+
3+
import (
4+
"sync"
5+
"time"
6+
)
7+
8+
// BoxCapabilities is a dashboard-side view of one box's probe table.
9+
// Carries the CapabilitiesResponse from the agent plus when it was last
10+
// observed. Handlers consume this to gate UI on what the agent reports
11+
// the box can do — e.g. hide HAProxy charts when the haproxy gear isn't
12+
// available on this host.
13+
type BoxCapabilities struct {
14+
Response *CapabilitiesResponse
15+
FetchedAt time.Time
16+
}
17+
18+
// Has reports whether the agent surfaced the given gear at all, regardless
19+
// of probe verdict. Use when "the agent is recent enough to know about
20+
// this gear" is the question — older agents that pre-date a gear simply
21+
// won't have it in their probe table.
22+
func (b *BoxCapabilities) Has(gearName string) bool {
23+
if b == nil || b.Response == nil {
24+
return false
25+
}
26+
_, ok := b.Response.Gears[gearName]
27+
return ok
28+
}
29+
30+
// IsAvailable reports whether the gear probed Available on the agent.
31+
// Returns false when capabilities are nil/unknown; callers wanting
32+
// fail-open behaviour should check Has() separately.
33+
func (b *BoxCapabilities) IsAvailable(gearName string) bool {
34+
if b == nil || b.Response == nil {
35+
return false
36+
}
37+
e, ok := b.Response.Gears[gearName]
38+
return ok && e.IsAvailable()
39+
}
40+
41+
// Entry returns the agent's probe entry for the gear if it's present in
42+
// the response. The second return is false when the gear isn't surfaced
43+
// or capabilities are nil.
44+
func (b *BoxCapabilities) Entry(gearName string) (CapabilityEntry, bool) {
45+
if b == nil || b.Response == nil {
46+
return CapabilityEntry{}, false
47+
}
48+
e, ok := b.Response.Gears[gearName]
49+
return e, ok
50+
}
51+
52+
// CapabilitiesCache memoises agent capability fetches per (box, agent
53+
// URL) pair. Dashboard pages call into this on every render that needs
54+
// to decide what to surface; without the cache, that's one synchronous
55+
// round-trip per render. TTL is short so a recently-restarted agent's
56+
// new probe table shows up without operator intervention; reconnect
57+
// events should invalidate explicitly via Invalidate() for an immediate
58+
// refresh.
59+
//
60+
// The cache key includes the agent URL (not just the box ID) so that an
61+
// operator editing a box's Agent URL — or re-pointing the same box ID
62+
// at a different host — gets fresh capabilities on the next render
63+
// rather than stale data from the prior agent until the 5-minute TTL
64+
// expires. The API key is intentionally not part of the key (rotations
65+
// don't change which gears the host runs; if the new key is wrong the
66+
// fetch errors and the negative-cache entry expires normally).
67+
//
68+
// Negative results (fetch errors, agent dead) are also cached for the
69+
// TTL window so a flaky or unreachable agent doesn't drag down every
70+
// page render with a fresh failed call.
71+
type CapabilitiesCache struct {
72+
mu sync.RWMutex
73+
entries map[cacheKey]*cachedCapabilities
74+
ttl time.Duration
75+
fetchTimeout time.Duration
76+
}
77+
78+
// cacheKey identifies one cached entry by (boxID, agentURL). Splitting
79+
// into a struct rather than concatenating strings sidesteps any chance
80+
// of separator collision in operator-supplied URLs.
81+
type cacheKey struct {
82+
boxID string
83+
agentURL string
84+
}
85+
86+
type cachedCapabilities struct {
87+
caps *BoxCapabilities // nil when the last fetch errored
88+
err error // captured on failure for callers that want it
89+
fetchedAt time.Time
90+
}
91+
92+
// NewCapabilitiesCache creates a cache with the given TTL and per-fetch
93+
// timeout. The timeout must be short — capability fetches sit on the
94+
// critical path of page renders, so a dead agent must not stall the UI.
95+
// 3s matches the value the Gears settings page used pre-cache.
96+
func NewCapabilitiesCache(ttl, fetchTimeout time.Duration) *CapabilitiesCache {
97+
return &CapabilitiesCache{
98+
entries: make(map[cacheKey]*cachedCapabilities),
99+
ttl: ttl,
100+
fetchTimeout: fetchTimeout,
101+
}
102+
}
103+
104+
// Get returns the cached capabilities for (boxID, agentURL). If the
105+
// entry is missing or older than the TTL, the cache fetches fresh
106+
// capabilities from agentURL using the supplied API key. On fetch error,
107+
// returns (nil, err); the error is also cached for the TTL window.
108+
// Callers should treat (nil, err) as "unknown" and fail open — locking
109+
// users out of pages because the agent is briefly unreachable is worse
110+
// than briefly showing gears that may not be available.
111+
//
112+
// Changing agentURL for the same boxID produces a different cache entry,
113+
// so config edits take effect on the next call rather than waiting out
114+
// the TTL. The previous entry expires naturally; no explicit cleanup
115+
// needed for the rare case of an operator-driven URL change.
116+
func (c *CapabilitiesCache) Get(boxID, agentURL, apiKey string) (*BoxCapabilities, error) {
117+
k := cacheKey{boxID: boxID, agentURL: agentURL}
118+
if entry, ok := c.lookup(k); ok {
119+
return entry.caps, entry.err
120+
}
121+
122+
client := NewClientWithTimeout(agentURL, apiKey, c.fetchTimeout)
123+
resp, err := client.GetCapabilities()
124+
125+
now := time.Now()
126+
var caps *BoxCapabilities
127+
if err == nil {
128+
caps = &BoxCapabilities{Response: resp, FetchedAt: now}
129+
}
130+
131+
c.mu.Lock()
132+
c.entries[k] = &cachedCapabilities{caps: caps, err: err, fetchedAt: now}
133+
c.mu.Unlock()
134+
135+
return caps, err
136+
}
137+
138+
// lookup returns a non-stale entry under the read lock, or (nil, false)
139+
// if missing/expired. Split out so Get's refresh path can drop the read
140+
// lock before doing network I/O.
141+
func (c *CapabilitiesCache) lookup(k cacheKey) (*cachedCapabilities, bool) {
142+
c.mu.RLock()
143+
defer c.mu.RUnlock()
144+
entry, ok := c.entries[k]
145+
if !ok {
146+
return nil, false
147+
}
148+
if time.Since(entry.fetchedAt) >= c.ttl {
149+
return nil, false
150+
}
151+
return entry, true
152+
}
153+
154+
// Invalidate drops every cached entry for boxID, regardless of the agent
155+
// URL it was fetched against. Call this when the box reconnects or when
156+
// the box's config changes (URL or API key edited), so the next Get
157+
// refetches against the current settings rather than at the next TTL
158+
// boundary.
159+
func (c *CapabilitiesCache) Invalidate(boxID string) {
160+
c.mu.Lock()
161+
defer c.mu.Unlock()
162+
for k := range c.entries {
163+
if k.boxID == boxID {
164+
delete(c.entries, k)
165+
}
166+
}
167+
}
168+
169+
// InvalidateAll drops every cached entry. Useful when global config that
170+
// affects probing changes (rare) and in tests.
171+
func (c *CapabilitiesCache) InvalidateAll() {
172+
c.mu.Lock()
173+
c.entries = make(map[cacheKey]*cachedCapabilities)
174+
c.mu.Unlock()
175+
}

0 commit comments

Comments
 (0)