Skip to content

Commit 669318f

Browse files
authored
refactor: rename Metrics gear URL /history → /metrics + themed 404 (#97)
* refactor: rename Metrics gear URL /history → /metrics; clarify the term The Metrics gear was historically called "history" because the page shows time-series data. That name leaked into the URL (/history), the API endpoints (/api/{boxID}/history/*), the templ file (history.templ), the handler functions (HistoryPage, APIStatsHistoryHandler, …), and assorted comments / docs / README entries. With nginx / Apache / Caddy / Traefik detection now landing (issue #95) and "metric source" / "primary source" terminology in the new code, the dual naming has become genuinely confusing. This commit renames the metrics-gear identity end-to-end: - Page route: /history → /metrics (plugin.go SidebarItem.Path, base.templ path-prefix match + sidebar links, gears-page.js). - API routes: /api/{boxID}/history/stats → /api/{boxID}/metrics/stats /api/{boxID}/history/metrics → /api/{boxID}/metrics/system /api/{boxID}/history/backend/{name} → /api/{boxID}/metrics/backend/{name} (Last one coexists with /metrics/backend/{name}/details — the time-series endpoint vs the drill-down details endpoint added in #87.) - Handler functions: HistoryPage → MetricsPage APIStatsHistoryHandler → APIMetricsStatsHandler APIBackendHistoryHandler → APIMetricsBackendHandler APISystemMetricsHistoryHandler → APIMetricsSystemHandler - File: pages/history.templ → pages/metrics.templ (templ component History() → Metrics()) - JS callers: history.templ + details.templ + chart_partials.templ (8 fetch() URLs updated). - Docs: metrics gear README routes table + architecture diagram + development snippet; cmd/server/main.go route-group comment; pages.go migration-history comment; permissions.go field comment; docs/gears.md gear list. Intentionally **kept** as legitimate "history" concepts (per design discussion): - DB tables stats_history, system_metrics_history, backend_history — the rows ARE historical records. Renaming requires a migration with no user-visible benefit; the names accurately describe what they contain. The DB query methods that read them (GetStatsHistory, GetBackendHistory, GetSystemMetricsHistory) keep their names for the same reason. - Snapshot interval / retention config: HistoryIntervalSeconds, store_history, history_retention_days — these control how the historical record is kept. Same reasoning. - Local `history` variable names inside the renamed handler functions — they reflect what the DB methods return. - OS-update apt/zypper/dnf/pacman history — distinct concept. /api/v1/system/updates/history on the agent, /os-updates/history on the dashboard, parseAptHistoryLog, /var/log/apt/history.log, etc. All untouched. - HAProxy config change history — distinct concept. /{boxID}/haproxy/config/history, templates/pages/haproxy_config/change_history.templ, etc. Untouched. Agent module: no changes. The agent's "history" references are all OS-update package-manager history (apt/zypper/dnf/pacman/apk/yum), which is the legitimate concept. Tests + build green on both modules. * feat: themed static 404 page for unmatched routes Wires chi's NotFound to a self-contained handler so a typo'd URL (or a bookmark to the now-renamed /history) lands on a small Gearbox-themed page instead of the browser's default 404. Particularly relevant after the /history → /metrics rename in this PR — anyone with a bookmark to the old URL will hit this. Security posture: the handler is deliberately static end-to-end. - No templ rendering, no auth middleware, no DB / agent lookups. - HTML is a Go const so there's no filesystem or template lookup at request time. - Inline CSS only (no <link> tags) so the response works even if the static-asset bundle didn't load. - No request data echoed into the response (test enforces this) — guards against the page becoming a reflection vector. - `Cache-Control: no-store` so a 404 doesn't outlive a deploy that later adds the missing route. Tests: - TestNotFoundHandlerStatusAndBody: 404 status, HTML body, Cache- Control, expected copy. - TestNotFoundHandlerDoesNotEchoRequestData: URL/header/cookie probes don't appear in the response body — handler is fully static. - TestNotFoundHandlerStableAcrossMethods: GET/POST/PUT/DELETE/PATCH all return the same 404 page. Visual: matches the dashboard's blue accent (#2563eb), uses system fonts (no external font loading), `prefers-color-scheme`-aware so it renders in dark mode without JS. * docs: fix grammar in metrics README (Copilot review nit)
1 parent 5819f15 commit 669318f

15 files changed

Lines changed: 275 additions & 70 deletions

File tree

docs/gears.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Client gears run in the **gearbox** web application (the monitoring client) and
138138
- `traffic` - Traffic analysis and visualization page
139139
- `alerts` - Alert management page
140140
- `services` - Service status and control page
141-
- `metrics` - System metrics and history page
141+
- `metrics` - Time-series system + HAProxy metrics page (`/metrics`)
142142

143143
### Gear Scope: Box vs System
144144

gearbox/cmd/server/main.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,12 @@ func main() {
452452
r.Use(gbmiddleware.SecurityHeaders)
453453
// Inject asset configuration for templates (CDN vs local assets)
454454
r.Use(gbmiddleware.InjectAssetConfig(cfg.UseLocalAssets))
455+
456+
// Themed 404 for unmatched routes. The handler is intentionally
457+
// static (inline HTML, no auth/DB/templ) so a typo'd URL can't be a
458+
// side channel for fingerprinting session state. See the handler's
459+
// own godoc for the rationale.
460+
r.NotFound(handler.NotFoundHandler)
455461
// Note: Timeout middleware is applied per-route group below
456462
// SSE endpoints need to bypass the timeout middleware
457463

@@ -627,7 +633,7 @@ func main() {
627633

628634
// Gear-registered routes
629635
// Gears handle: / (haproxy overview), /status-grid (haproxy), /logs (logs),
630-
// /services (services), /history (metrics), /certificates (certificates),
636+
// /services (services), /metrics (metrics gear), /certificates (certificates),
631637
// /traffic (traffic), /alerts (alerts)
632638
gearManager.RegisterRoutes(r)
633639

@@ -676,10 +682,13 @@ func main() {
676682
r.Get("/{boxID}/charts/error-rates", h.APIChartsErrorRatesHandler)
677683
r.Get("/{boxID}/logs/{logName}", h.APILogsHandler)
678684
r.Get("/{boxID}/log-sources", h.APILogSourcesHandler) // Get enabled log sources
679-
// History API endpoints
680-
r.Get("/{boxID}/history/stats", h.APIStatsHistoryHandler)
681-
r.Get("/{boxID}/history/metrics", h.APISystemMetricsHistoryHandler)
682-
r.Get("/{boxID}/history/backend/{backendName}", h.APIBackendHistoryHandler)
685+
// Metrics gear — time-series endpoints (HAProxy stats,
686+
// system metrics, per-backend stats). These power the
687+
// charts on the /metrics page; the /metrics/* "v2"
688+
// endpoints just below power the KPI band + insights.
689+
r.Get("/{boxID}/metrics/stats", h.APIMetricsStatsHandler)
690+
r.Get("/{boxID}/metrics/system", h.APIMetricsSystemHandler)
691+
r.Get("/{boxID}/metrics/backend/{backendName}", h.APIMetricsBackendHandler)
683692
r.Get("/{boxID}/incidents", h.APIIncidentsHandler)
684693

685694
// Metrics gear v2 — insights & drill-down endpoints

gearbox/internal/framework/handler/api_stats.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,12 @@ func (h *Handler) APISystemMetricsHandler(w http.ResponseWriter, r *http.Request
102102
h.writeJSON(w, response)
103103
}
104104

105-
// APIStatsHistoryHandler returns historical stats data.
106-
func (h *Handler) APIStatsHistoryHandler(w http.ResponseWriter, r *http.Request) {
105+
// APIMetricsStatsHandler returns time-series HAProxy stats for the
106+
// Metrics page's per-backend chart. Served at /api/{boxID}/metrics/stats.
107+
// The underlying DB method is still named GetStatsHistory because the
108+
// data it returns IS historical records; the URL renamed for clarity
109+
// with issue #97.
110+
func (h *Handler) APIMetricsStatsHandler(w http.ResponseWriter, r *http.Request) {
107111
boxID := chi.URLParam(r, "boxID")
108112
if boxID == "" {
109113
http.Error(w, "Server ID required", http.StatusBadRequest)
@@ -142,8 +146,12 @@ func (h *Handler) APIStatsHistoryHandler(w http.ResponseWriter, r *http.Request)
142146
})
143147
}
144148

145-
// APIBackendHistoryHandler returns historical data for a specific backend.
146-
func (h *Handler) APIBackendHistoryHandler(w http.ResponseWriter, r *http.Request) {
149+
// APIMetricsBackendHandler returns time-series stats for a specific
150+
// backend on the Metrics page. Served at
151+
// /api/{boxID}/metrics/backend/{backendName}. Coexists with the
152+
// /metrics/backend/{name}/details drill-down (which returns aggregate
153+
// insights rather than the time-series).
154+
func (h *Handler) APIMetricsBackendHandler(w http.ResponseWriter, r *http.Request) {
147155
// Check if user has permission to view metrics
148156
if !h.authManager.HasPermission(r, models.ComponentMetrics, models.PermissionView) {
149157
http.Error(w, "Forbidden: insufficient permissions to view metrics", http.StatusForbidden)
@@ -191,8 +199,10 @@ func (h *Handler) APIBackendHistoryHandler(w http.ResponseWriter, r *http.Reques
191199
})
192200
}
193201

194-
// APISystemMetricsHistoryHandler returns historical system metrics.
195-
func (h *Handler) APISystemMetricsHistoryHandler(w http.ResponseWriter, r *http.Request) {
202+
// APIMetricsSystemHandler returns time-series host-level metrics for
203+
// the Metrics page (CPU, memory, disk, network). Served at
204+
// /api/{boxID}/metrics/system.
205+
func (h *Handler) APIMetricsSystemHandler(w http.ResponseWriter, r *http.Request) {
196206
// Check if user has permission to view metrics
197207
if !h.authManager.HasPermission(r, models.ComponentMetrics, models.PermissionView) {
198208
http.Error(w, "Forbidden: insufficient permissions to view metrics", http.StatusForbidden)
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package handler
2+
3+
import "net/http"
4+
5+
// NotFoundHandler serves a static, self-contained 404 page for any URL
6+
// that doesn't match a registered route. Wired in main.go via
7+
// chi's r.NotFound().
8+
//
9+
// Deliberately bypasses every dashboard concern beyond the HTTP
10+
// envelope: no templ rendering, no auth middleware, no DB / agent
11+
// lookups, no user-context injection. A page-not-found response for a
12+
// randomly-typed URL must not be a side channel for fingerprinting
13+
// session state or for accidentally exposing data that a logged-out
14+
// caller shouldn't see. The HTML is a const so there's no filesystem
15+
// or template lookup at request time either.
16+
//
17+
// Visually the page matches the dashboard's palette (blue accent,
18+
// system fonts, prefers-color-scheme-aware) and includes a single
19+
// link back to "/". All styles are inline so the response works even
20+
// if the static-asset bundle didn't load.
21+
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
22+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
23+
// 404s shouldn't be cached — the next deploy might add the route.
24+
w.Header().Set("Cache-Control", "no-store")
25+
w.WriteHeader(http.StatusNotFound)
26+
_, _ = w.Write([]byte(notFoundHTML))
27+
}
28+
29+
// notFoundHTML is the entire 404 response body. Kept as a const so the
30+
// handler has no runtime template or filesystem dependency — security
31+
// posture comment on NotFoundHandler explains why this matters.
32+
const notFoundHTML = `<!doctype html>
33+
<html lang="en">
34+
<head>
35+
<meta charset="utf-8">
36+
<title>404 — Page not found · Gearbox</title>
37+
<meta name="viewport" content="width=device-width,initial-scale=1">
38+
<meta name="robots" content="noindex">
39+
<style>
40+
:root { color-scheme: light dark; }
41+
* { box-sizing: border-box; }
42+
html, body { height: 100%; margin: 0; }
43+
body {
44+
display: flex;
45+
align-items: center;
46+
justify-content: center;
47+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
48+
background: #f8fafc;
49+
color: #0f172a;
50+
padding: 1.5rem;
51+
}
52+
@media (prefers-color-scheme: dark) {
53+
body { background: #0f172a; color: #e2e8f0; }
54+
.footer { color: #64748b; }
55+
}
56+
.card {
57+
text-align: center;
58+
max-width: 32rem;
59+
padding: 2rem;
60+
}
61+
h1 {
62+
font-size: 5rem;
63+
font-weight: 700;
64+
margin: 0 0 0.25rem;
65+
letter-spacing: -0.025em;
66+
color: #2563eb;
67+
line-height: 1;
68+
}
69+
.lead {
70+
font-size: 1.25rem;
71+
font-weight: 600;
72+
margin: 0 0 1rem;
73+
}
74+
p {
75+
margin: 0.5rem 0;
76+
line-height: 1.6;
77+
opacity: 0.85;
78+
}
79+
a.home {
80+
display: inline-block;
81+
margin-top: 1.5rem;
82+
padding: 0.625rem 1.25rem;
83+
background: #2563eb;
84+
color: #ffffff;
85+
text-decoration: none;
86+
border-radius: 0.5rem;
87+
font-weight: 500;
88+
font-size: 0.9375rem;
89+
transition: background-color 120ms ease;
90+
}
91+
a.home:hover, a.home:focus { background: #1d4ed8; }
92+
.footer {
93+
margin-top: 2rem;
94+
font-size: 0.75rem;
95+
color: #94a3b8;
96+
letter-spacing: 0.04em;
97+
text-transform: uppercase;
98+
}
99+
</style>
100+
</head>
101+
<body>
102+
<main class="card">
103+
<h1>404</h1>
104+
<p class="lead">Page not found</p>
105+
<p>The URL you requested isn't registered on this dashboard.</p>
106+
<a class="home" href="/">Back to dashboard</a>
107+
<p class="footer">Gearbox</p>
108+
</main>
109+
</body>
110+
</html>`
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package handler
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestNotFoundHandlerStatusAndBody(t *testing.T) {
11+
req := httptest.NewRequest(http.MethodGet, "/anything-not-registered", nil)
12+
w := httptest.NewRecorder()
13+
14+
NotFoundHandler(w, req)
15+
16+
if w.Code != http.StatusNotFound {
17+
t.Errorf("status = %d, want 404", w.Code)
18+
}
19+
ct := w.Header().Get("Content-Type")
20+
if !strings.HasPrefix(ct, "text/html") {
21+
t.Errorf("Content-Type = %q, want HTML", ct)
22+
}
23+
if cc := w.Header().Get("Cache-Control"); cc != "no-store" {
24+
t.Errorf("Cache-Control = %q, want no-store", cc)
25+
}
26+
body := w.Body.String()
27+
for _, want := range []string{"<!doctype html>", "404", "Page not found", "Back to dashboard"} {
28+
if !strings.Contains(body, want) {
29+
t.Errorf("body should contain %q", want)
30+
}
31+
}
32+
}
33+
34+
// TestNotFoundHandlerDoesNotEchoRequestData guards against accidentally
35+
// turning the 404 page into a reflection vector. The handler should
36+
// never embed any part of the request URL, headers, cookies, or body
37+
// into the response — the point of the static page is that a typo'd
38+
// URL produces a fully deterministic response.
39+
func TestNotFoundHandlerDoesNotEchoRequestData(t *testing.T) {
40+
const probe = "GEARBOX-PROBE-MARKER-39df09a1"
41+
req := httptest.NewRequest(http.MethodGet, "/"+probe, nil)
42+
req.Header.Set("X-Probe", probe)
43+
req.Header.Set("Cookie", "session="+probe)
44+
req.Header.Set("Referer", "https://example.com/"+probe)
45+
w := httptest.NewRecorder()
46+
47+
NotFoundHandler(w, req)
48+
49+
if strings.Contains(w.Body.String(), probe) {
50+
t.Errorf("response body contains request-supplied marker %q — handler is reflecting request data", probe)
51+
}
52+
}
53+
54+
func TestNotFoundHandlerStableAcrossMethods(t *testing.T) {
55+
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
56+
t.Run(method, func(t *testing.T) {
57+
req := httptest.NewRequest(method, "/missing", nil)
58+
w := httptest.NewRecorder()
59+
NotFoundHandler(w, req)
60+
if w.Code != http.StatusNotFound {
61+
t.Errorf("%s: status = %d, want 404", method, w.Code)
62+
}
63+
})
64+
}
65+
}

gearbox/internal/framework/handler/pages.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import (
1515
// - OverviewPage -> plugins/haproxy
1616
// - StatusGridPage -> plugins/haproxy
1717
// - LogsPage -> plugins/logs
18-
// - HistoryPage -> plugins/metrics
18+
// - MetricsPage -> plugins/metrics
1919
// - ServicesPage -> plugins/services
2020
// - CertificatesPage -> plugins/certificates
2121
// - TrafficPage -> plugins/traffic

gearbox/internal/framework/models/permissions.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ func GetAvailablePermissionsForComponent(c Component) []Permission {
335335
}
336336
case ComponentMetrics:
337337
return []Permission{
338-
PermissionView, // View metrics/history page and data
338+
PermissionView, // View the Metrics page and its data
339339
PermissionConfigure, // Configure metrics storage settings
340340
}
341341
case ComponentGears:

gearbox/internal/framework/templates/layouts/base.templ

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func gearLabelForPath(path string) string {
3636
return ""
3737
case path == "/haproxy" || strings.HasPrefix(path, "/haproxy/"):
3838
return "HAProxy"
39-
case path == "/history" || strings.HasPrefix(path, "/history/"):
39+
case path == "/metrics" || strings.HasPrefix(path, "/metrics/"):
4040
return "Metrics"
4141
case path == "/logs" || strings.HasPrefix(path, "/logs/"):
4242
return "Logs"
@@ -2175,7 +2175,7 @@ func integrationPath(name string) string {
21752175
case "haproxy":
21762176
return "/haproxy"
21772177
case "metrics":
2178-
return "/history"
2178+
return "/metrics"
21792179
case "logs":
21802180
return "/logs"
21812181
case "services":
@@ -2287,7 +2287,7 @@ templ OrderedIntegrationLinks(currentPath string) {
22872287
@SidebarLinkWithIntegration("/haproxy", "HAProxy", SidebarIconHAProxy(), currentPath, "haproxy")
22882288
}
22892289
if canViewIntegration(ctx, "metrics") {
2290-
@SidebarLinkWithIntegration("/history", "Metrics", SidebarIconMetrics(), currentPath, "metrics")
2290+
@SidebarLinkWithIntegration("/metrics", "Metrics", SidebarIconMetrics(), currentPath, "metrics")
22912291
}
22922292
if canViewIntegration(ctx, "logs") {
22932293
@SidebarLinkWithIntegration("/logs", "Logs", SidebarIconLogs(), currentPath, "logs")
@@ -2332,7 +2332,7 @@ templ renderIntegrationLink(name string, currentPath string) {
23322332
case "haproxy":
23332333
@SidebarLinkDraggable("/haproxy", "HAProxy", SidebarIconHAProxy(), currentPath, "haproxy")
23342334
case "metrics":
2335-
@SidebarLinkDraggable("/history", "Metrics", SidebarIconMetrics(), currentPath, "metrics")
2335+
@SidebarLinkDraggable("/metrics", "Metrics", SidebarIconMetrics(), currentPath, "metrics")
23362336
case "logs":
23372337
@SidebarLinkDraggable("/logs", "Logs", SidebarIconLogs(), currentPath, "logs")
23382338
case "services":

gearbox/internal/framework/templates/pages/details.templ

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,7 +714,7 @@ templ BackendDetail(user *models.User, backendName string, stats *models.HAProxy
714714
function loadBackendHistory() {
715715
updateResolutionLabel();
716716
var hours = document.getElementById('hours-select').value;
717-
fetch('/api/' + backendServerID + '/history/backend/' + encodeURIComponent(backendNameVar) + '?hours=' + hours)
717+
fetch('/api/' + backendServerID + '/metrics/backend/' + encodeURIComponent(backendNameVar) + '?hours=' + hours)
718718
.then(function(response) { return response.json(); })
719719
.then(function(result) {
720720
cachedBackendData = result.data || [];

gearbox/internal/framework/templates/pages/history.templ renamed to gearbox/internal/framework/templates/pages/metrics.templ

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import "github.com/sarg3nt/gearbox/internal/framework/models"
44
import "github.com/sarg3nt/gearbox/internal/framework/templates/layouts"
55
import "github.com/sarg3nt/gearbox/internal/framework/templates/components"
66

7-
templ History(user *models.User, servers []models.BoxConfig) {
8-
@layouts.Base("Metrics", user, "/history") {
7+
templ Metrics(user *models.User, servers []models.BoxConfig) {
8+
@layouts.Base("Metrics", user, "/metrics") {
99
<!-- Hidden container for page header content. Title + box selector
1010
live in the global header breadcrumb + chip. -->
1111
<div id="page-header-source" class="hidden">
@@ -843,12 +843,12 @@ templ History(user *models.User, servers []models.BoxConfig) {
843843
await applyCapabilities(serverID);
844844

845845
try {
846-
// Fetch stats history
847-
const statsResponse = await fetch(`/api/${serverID}/history/stats?hours=${hours}`);
846+
// Fetch HAProxy stats time series
847+
const statsResponse = await fetch(`/api/${serverID}/metrics/stats?hours=${hours}`);
848848
const statsData = await statsResponse.json();
849849

850-
// Fetch system metrics history
851-
const metricsResponse = await fetch(`/api/${serverID}/history/metrics?hours=${hours}`);
850+
// Fetch system-metrics time series
851+
const metricsResponse = await fetch(`/api/${serverID}/metrics/system?hours=${hours}`);
852852
const metricsData = await metricsResponse.json();
853853

854854
// Cache the data for fullscreen toggle

0 commit comments

Comments
 (0)