Skip to content

feat(#91): metrics gear — source-aware, no-HAProxy mode (phases 0-2) - #93

Merged
sarg3nt merged 3 commits into
mainfrom
feature/metrics-source-agnostic-phase-0-2
May 14, 2026
Merged

feat(#91): metrics gear — source-aware, no-HAProxy mode (phases 0-2)#93
sarg3nt merged 3 commits into
mainfrom
feature/metrics-source-agnostic-phase-0-2

Conversation

@sarg3nt

@sarg3nt sarg3nt commented May 14, 2026

Copy link
Copy Markdown
Owner

Summary

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.

This PR lands phases 0-2 of #91. Each phase is independently valuable; together they ship the foundation every later phase (nginx, Apache, Caddy, Docker) builds on.

Phase 0 — Capability manifest plumbing (dashboard side)

  • New agent.CapabilitiesCache (5-min TTL, 3s fetch timeout, negative caching) memoises per-box probe tables so gear-page handlers don't fire a fresh agent call on every render.
  • Handler-level getBoxCapabilities() + BoxCapabilities accessor with Has / IsAvailable / Entry helpers.
  • Reconnect-driven invalidation: handler subscribes to EventTypeServerConnected and drops the cache for that box so a restarted agent's new probe table is reflected immediately rather than at the next TTL boundary.
  • New GET /api/{boxID}/capabilities endpoint surfaces the cached manifest to the auth-scoped frontend.
  • filterGearsByAgentCapabilities (issue Box gear configuration improvements #71) refactored onto the cache — removes one synchronous agent call per Gears-page render.

Note: the agent's /api/v1/system/capabilities endpoint already shipped under #71. This PR adds the dashboard-side caching + accessor + endpoint the Phase 0 spec calls for.

Phase 1 — Source attribution in the UI

  • Chart card titles carry source-prefix badges ("HAProxy: …" or "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 applyCapabilities() hides [data-source="haproxy"] chart cards + Error Insights when haproxy isn't available; shows an empty-state banner pointing to install / enable.
  • Fail-open everywhere — capability fetches that error don't strip the page; the dashboard reverts to "HAProxy assumed present" rather than locking the user out of cards they may need.

What's intentionally not in this PR

  • CPU%, uptime, failed-systemd KPIs — the collector doesn't persist these in system_metrics_history today. They'll follow when the collector starts saving them.
  • Service detection (nginx, Apache, Caddy, Traefik) — Phase 3 of the issue. Agent-side work; intentionally separate so this PR stays focused.
  • Per-source charts (nginx metrics, Caddy metrics, etc.) — Phases 4+ of the issue.
  • Cross-source aggregates — Phase 8.

Test plan

  • make test — 9 packages pass on gearbox; no regressions on gearbox-agent.
  • New unit tests:
    • CapabilitiesCache — TTL caching, refresh past TTL, Invalidate, InvalidateAll, negative caching, nil-safety on BoxCapabilities accessors.
    • avgPositiveSysField + sysSparkline — host-KPI counterparts of the existing stat helpers.
  • go vet ./... clean across both modules.
  • Manual smoke: /history on light-hugger (HAProxy box) shows HAProxy + Host KPIs and chart titles read "HAProxy: …" / "Host: …".
  • Manual smoke: /history on a box without HAProxy (mjolnir?) shows the no-HAProxy banner, hides HAProxy cards and Error Insights, and renders just the Host KPI cards + Host chart cards.
  • Manual smoke: reconnecting an agent updates capabilities within seconds (not 5 min).

References

sarg3nt added 2 commits May 14, 2026 11:29
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
Copilot AI review requested due to automatic review settings May 14, 2026 18:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds dashboard-side capability awareness for the Metrics gear so /history can attribute metrics to sources and degrade when HAProxy is unavailable.

Changes:

  • Adds cached per-box agent capabilities and exposes them via /api/{boxID}/capabilities.
  • Updates Metrics UI with source badges, HAProxy-specific hiding, and no-HAProxy messaging.
  • Adds host KPI cards and helper/cache unit tests.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
.gitignore Ignores local config file.
gearbox/cmd/server/main.go Registers the capabilities API route.
gearbox/internal/framework/agent/capabilities_cache.go Adds per-box capabilities cache/accessors.
gearbox/internal/framework/agent/capabilities_cache_test.go Tests capabilities cache behavior.
gearbox/internal/framework/handler/api_capabilities.go Adds dashboard capabilities API handler.
gearbox/internal/framework/handler/api_metrics_insights.go Adds source fields, HAProxy gating, and host KPI cards.
gearbox/internal/framework/handler/api_metrics_insights_helpers.go Adds system-metrics KPI helper functions.
gearbox/internal/framework/handler/api_metrics_insights_test.go Tests new system-metrics helpers.
gearbox/internal/framework/handler/gears.go Reuses capabilities cache for gear filtering.
gearbox/internal/framework/handler/handler.go Wires capabilities cache and reconnect invalidation.
gearbox/internal/framework/templates/pages/history.templ Adds source UI badges and HAProxy capability-based hiding.
Comments suppressed due to low confidence (4)

gearbox/internal/framework/handler/api_metrics_insights.go:367

  • Using avgPositiveSysField for load average drops legitimate zero readings from idle hosts, so a window with samples like 0, 0, 1.0 is reported as 1.0 instead of ~0.33. system_metrics_history stores load_average_1 directly from the agent, where zero is a valid value, unlike the HAProxy response-time buckets this helper mirrors.
	currLoad := avgPositiveSysField(curr, func(s database.SystemMetricsSnapshot) float64 { return s.LoadAverage1 })
	prevLoad := avgPositiveSysField(prev, func(s database.SystemMetricsSnapshot) float64 { return s.LoadAverage1 })

gearbox/internal/framework/handler/api_metrics_insights.go:291

  • The function comment says hostKPICards returns a network KPI, but the implementation only builds memory, disk, and load cards. This stale documentation makes it look like a network card is missing from the code when it appears to be intentionally omitted from this phase.
// hostKPICards returns the host-level KPI cards (memory, disk, load,
// network). Computed from the dashboard's system_metrics_history table.

gearbox/internal/framework/templates/pages/history.templ:893

  • Capability gating uses the same hidden class that toggleFullscreen() uses for temporary fullscreen state. On a no-HAProxy box, entering and then exiting fullscreen on a host chart will run the existing fullscreen exit path that removes hidden from every other .chart-card, making the HAProxy cards visible again despite the capability result. Use a separate capability-hidden state or reapply capability filtering when leaving fullscreen.
			document.querySelectorAll('[data-source="haproxy"]').forEach(function(el) {
				el.classList.toggle('hidden', !haproxyAvailable);

gearbox/internal/framework/templates/pages/history.templ:214

  • This copy says the Error Insights data is reported by HAProxy logs, but the panel is populated from /metrics/error-breakdown, which aggregates the traffic_flows table built from HAProxy traffic/stats data rather than log parsing. That can mislead users about the data source and how to troubleshoot missing rows.
						<h3 class="text-xl font-semibold text-gray-800 dark:text-gray-100">HAProxy Error Insights</h3>
						<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
							Where the 4xx/5xx responses in this window are coming from, as reported by HAProxy logs. Click any row to drill in.

Comment thread gearbox/internal/framework/handler/api_capabilities.go
Comment thread gearbox/internal/framework/templates/pages/history.templ Outdated
Comment thread gearbox/internal/framework/agent/capabilities_cache.go
Comment thread gearbox/internal/framework/handler/api_metrics_insights.go Outdated
- 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.
@sarg3nt
sarg3nt merged commit 2a3fc24 into main May 14, 2026
22 checks passed
sarg3nt added a commit that referenced this pull request May 15, 2026
)

* feat(#95): agent — phase 3 source detection (nginx, Apache, Caddy, Traefik, Docker, host)

Completes the remaining detection work for issue #95 on top of #96's
primary-source selection. Six new probe-only gears land their verdicts
in the capability manifest so the dashboard sees a complete picture of
which HTTP / container sources exist on each host. No metrics
collection yet — that lands in Phase 4+ per-source.

The four web-server detectors declare CategoryHTTPRequests via
MetricSourceGear, so the resolver from #96 now actually picks between
alternatives — set GEARBOX_AGENT_HTTP_SOURCE=nginx on a host with both
HAProxy and nginx and the manifest flips.

Per-source env overrides for non-default surfaces:
  NGINX_STATUS_URL / NGINX_CONFIG_FILE
  APACHE_STATUS_URL / APACHE_CONFIG_FILE
  CADDY_ADMIN_URL
  TRAEFIK_METRICS_URL
  DOCKER_SOCKET

Also drops the dead internal/framework/discovery/ package (superseded
by the ProbeableGear interface from #93; nothing imported it). The
discovery/docker.go detection logic was modest — binary lookup +
os.Stat + systemctl is-active — and lives on in the new docker gear
with the new manifest plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#95): address Copilot review findings on PR #100

Substantive:
- probe.isLoopback: parse URL with net/url + check net.IP.IsLoopback so
  userinfo-spoofed URLs like https://localhost@evil.com/ can't trick the
  helper into skipping TLS verification against evil.com.
- probe.HTTPGet: validate maxBody > 0; return error instead of silently
  returning empty body that would hide sentinel mismatches.
- docker probe: capture stat's FileInfo and check Mode()&ModeSocket so a
  regular file or directory at the socket path is flagged Inaccessible
  with a reason that points at the bind-mount mismatch.
- traefik probe: track the last non-matching HTTP response across the
  fallback URL list; the Inaccessible reason now distinguishes "wrong
  service on this port (200 without sentinel)" from "no listener
  (connection refused)" so operators debug the right cause.

Comment/text alignment:
- caddy/traefik/apache Info().Description: Phase 4+ → Phase 7+ to match
  each file's package header and the docs.
- nginx wellKnownConfigPaths comment: "all four" → "all three" (slice
  has three entries).
- nginx Probe comment: "two `nginx -V` runs" → "one `nginx -v` and one
  `nginx -V`" — accurate process-invocation count.
- docs/source-detection.md: "six-step decision tree" → "four-step" to
  match the rendered numbered list.

New tests:
- probe_test.go: isLoopback covers userinfo spoof regression, plain
  loopback variants, and unparseable input. HTTPGet rejects non-positive
  maxBody.
- docker plugin_probe_test.go: new regression test for "path exists but
  is not a socket". Existing fakeFileInfo now carries a Mode field.
- traefik plugin_probe_test.go: new test asserts the Inaccessible reason
  distinguishes "200 without Traefik sentinel" from generic
  "unreachable".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants