Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/gears.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ Client gears run in the **gearbox** web application (the monitoring client) and
- `traffic` - Traffic analysis and visualization page
- `alerts` - Alert management page
- `services` - Service status and control page
- `metrics` - System metrics and history page
- `metrics` - Time-series system + HAProxy metrics page (`/metrics`)

### Gear Scope: Box vs System

Expand Down
19 changes: 14 additions & 5 deletions gearbox/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,12 @@ func main() {
r.Use(gbmiddleware.SecurityHeaders)
// Inject asset configuration for templates (CDN vs local assets)
r.Use(gbmiddleware.InjectAssetConfig(cfg.UseLocalAssets))

// Themed 404 for unmatched routes. The handler is intentionally
// static (inline HTML, no auth/DB/templ) so a typo'd URL can't be a
// side channel for fingerprinting session state. See the handler's
// own godoc for the rationale.
r.NotFound(handler.NotFoundHandler)
// Note: Timeout middleware is applied per-route group below
// SSE endpoints need to bypass the timeout middleware

Expand Down Expand Up @@ -627,7 +633,7 @@ func main() {

// Gear-registered routes
// Gears handle: / (haproxy overview), /status-grid (haproxy), /logs (logs),
// /services (services), /history (metrics), /certificates (certificates),
// /services (services), /metrics (metrics gear), /certificates (certificates),
// /traffic (traffic), /alerts (alerts)
gearManager.RegisterRoutes(r)

Expand Down Expand Up @@ -676,10 +682,13 @@ func main() {
r.Get("/{boxID}/charts/error-rates", h.APIChartsErrorRatesHandler)
r.Get("/{boxID}/logs/{logName}", h.APILogsHandler)
r.Get("/{boxID}/log-sources", h.APILogSourcesHandler) // Get enabled log sources
// History API endpoints
r.Get("/{boxID}/history/stats", h.APIStatsHistoryHandler)
r.Get("/{boxID}/history/metrics", h.APISystemMetricsHistoryHandler)
r.Get("/{boxID}/history/backend/{backendName}", h.APIBackendHistoryHandler)
// Metrics gear — time-series endpoints (HAProxy stats,
// system metrics, per-backend stats). These power the
// charts on the /metrics page; the /metrics/* "v2"
// endpoints just below power the KPI band + insights.
r.Get("/{boxID}/metrics/stats", h.APIMetricsStatsHandler)
r.Get("/{boxID}/metrics/system", h.APIMetricsSystemHandler)
r.Get("/{boxID}/metrics/backend/{backendName}", h.APIMetricsBackendHandler)
r.Get("/{boxID}/incidents", h.APIIncidentsHandler)

// Metrics gear v2 — insights & drill-down endpoints
Expand Down
22 changes: 16 additions & 6 deletions gearbox/internal/framework/handler/api_stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,12 @@ func (h *Handler) APISystemMetricsHandler(w http.ResponseWriter, r *http.Request
h.writeJSON(w, response)
}

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

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

// APISystemMetricsHistoryHandler returns historical system metrics.
func (h *Handler) APISystemMetricsHistoryHandler(w http.ResponseWriter, r *http.Request) {
// APIMetricsSystemHandler returns time-series host-level metrics for
// the Metrics page (CPU, memory, disk, network). Served at
// /api/{boxID}/metrics/system.
func (h *Handler) APIMetricsSystemHandler(w http.ResponseWriter, r *http.Request) {
// Check if user has permission to view metrics
if !h.authManager.HasPermission(r, models.ComponentMetrics, models.PermissionView) {
http.Error(w, "Forbidden: insufficient permissions to view metrics", http.StatusForbidden)
Expand Down
110 changes: 110 additions & 0 deletions gearbox/internal/framework/handler/not_found.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package handler

import "net/http"

// NotFoundHandler serves a static, self-contained 404 page for any URL
// that doesn't match a registered route. Wired in main.go via
// chi's r.NotFound().
//
// Deliberately bypasses every dashboard concern beyond the HTTP
// envelope: no templ rendering, no auth middleware, no DB / agent
// lookups, no user-context injection. A page-not-found response for a
// randomly-typed URL must not be a side channel for fingerprinting
// session state or for accidentally exposing data that a logged-out
// caller shouldn't see. The HTML is a const so there's no filesystem
// or template lookup at request time either.
//
// Visually the page matches the dashboard's palette (blue accent,
// system fonts, prefers-color-scheme-aware) and includes a single
// link back to "/". All styles are inline so the response works even
// if the static-asset bundle didn't load.
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// 404s shouldn't be cached — the next deploy might add the route.
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(notFoundHTML))
}

// notFoundHTML is the entire 404 response body. Kept as a const so the
// handler has no runtime template or filesystem dependency — security
// posture comment on NotFoundHandler explains why this matters.
const notFoundHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>404 — Page not found · Gearbox</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex">
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
display: flex;
align-items: center;
justify-content: center;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f8fafc;
color: #0f172a;
padding: 1.5rem;
}
@media (prefers-color-scheme: dark) {
body { background: #0f172a; color: #e2e8f0; }
.footer { color: #64748b; }
}
.card {
text-align: center;
max-width: 32rem;
padding: 2rem;
}
h1 {
font-size: 5rem;
font-weight: 700;
margin: 0 0 0.25rem;
letter-spacing: -0.025em;
color: #2563eb;
line-height: 1;
}
.lead {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 1rem;
}
p {
margin: 0.5rem 0;
line-height: 1.6;
opacity: 0.85;
}
a.home {
display: inline-block;
margin-top: 1.5rem;
padding: 0.625rem 1.25rem;
background: #2563eb;
color: #ffffff;
text-decoration: none;
border-radius: 0.5rem;
font-weight: 500;
font-size: 0.9375rem;
transition: background-color 120ms ease;
}
a.home:hover, a.home:focus { background: #1d4ed8; }
.footer {
margin-top: 2rem;
font-size: 0.75rem;
color: #94a3b8;
letter-spacing: 0.04em;
text-transform: uppercase;
}
</style>
</head>
<body>
<main class="card">
<h1>404</h1>
<p class="lead">Page not found</p>
<p>The URL you requested isn't registered on this dashboard.</p>
<a class="home" href="/">Back to dashboard</a>
<p class="footer">Gearbox</p>
</main>
</body>
</html>`
65 changes: 65 additions & 0 deletions gearbox/internal/framework/handler/not_found_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package handler

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestNotFoundHandlerStatusAndBody(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/anything-not-registered", nil)
w := httptest.NewRecorder()

NotFoundHandler(w, req)

if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q, want HTML", ct)
}
if cc := w.Header().Get("Cache-Control"); cc != "no-store" {
t.Errorf("Cache-Control = %q, want no-store", cc)
}
body := w.Body.String()
for _, want := range []string{"<!doctype html>", "404", "Page not found", "Back to dashboard"} {
if !strings.Contains(body, want) {
t.Errorf("body should contain %q", want)
}
}
}

// TestNotFoundHandlerDoesNotEchoRequestData guards against accidentally
// turning the 404 page into a reflection vector. The handler should
// never embed any part of the request URL, headers, cookies, or body
// into the response — the point of the static page is that a typo'd
// URL produces a fully deterministic response.
func TestNotFoundHandlerDoesNotEchoRequestData(t *testing.T) {
const probe = "GEARBOX-PROBE-MARKER-39df09a1"
req := httptest.NewRequest(http.MethodGet, "/"+probe, nil)
req.Header.Set("X-Probe", probe)
req.Header.Set("Cookie", "session="+probe)
req.Header.Set("Referer", "https://example.com/"+probe)
w := httptest.NewRecorder()

NotFoundHandler(w, req)

if strings.Contains(w.Body.String(), probe) {
t.Errorf("response body contains request-supplied marker %q — handler is reflecting request data", probe)
}
}

func TestNotFoundHandlerStableAcrossMethods(t *testing.T) {
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
t.Run(method, func(t *testing.T) {
req := httptest.NewRequest(method, "/missing", nil)
w := httptest.NewRecorder()
NotFoundHandler(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("%s: status = %d, want 404", method, w.Code)
}
})
}
}
2 changes: 1 addition & 1 deletion gearbox/internal/framework/handler/pages.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
// - OverviewPage -> plugins/haproxy
// - StatusGridPage -> plugins/haproxy
// - LogsPage -> plugins/logs
// - HistoryPage -> plugins/metrics
// - MetricsPage -> plugins/metrics
// - ServicesPage -> plugins/services
// - CertificatesPage -> plugins/certificates
// - TrafficPage -> plugins/traffic
Expand Down
2 changes: 1 addition & 1 deletion gearbox/internal/framework/models/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ func GetAvailablePermissionsForComponent(c Component) []Permission {
}
case ComponentMetrics:
return []Permission{
PermissionView, // View metrics/history page and data
PermissionView, // View the Metrics page and its data
PermissionConfigure, // Configure metrics storage settings
}
case ComponentGears:
Expand Down
8 changes: 4 additions & 4 deletions gearbox/internal/framework/templates/layouts/base.templ
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func gearLabelForPath(path string) string {
return ""
case path == "/haproxy" || strings.HasPrefix(path, "/haproxy/"):
return "HAProxy"
case path == "/history" || strings.HasPrefix(path, "/history/"):
case path == "/metrics" || strings.HasPrefix(path, "/metrics/"):
return "Metrics"
case path == "/logs" || strings.HasPrefix(path, "/logs/"):
return "Logs"
Expand Down Expand Up @@ -2175,7 +2175,7 @@ func integrationPath(name string) string {
case "haproxy":
return "/haproxy"
case "metrics":
return "/history"
return "/metrics"
case "logs":
return "/logs"
case "services":
Expand Down Expand Up @@ -2287,7 +2287,7 @@ templ OrderedIntegrationLinks(currentPath string) {
@SidebarLinkWithIntegration("/haproxy", "HAProxy", SidebarIconHAProxy(), currentPath, "haproxy")
}
if canViewIntegration(ctx, "metrics") {
@SidebarLinkWithIntegration("/history", "Metrics", SidebarIconMetrics(), currentPath, "metrics")
@SidebarLinkWithIntegration("/metrics", "Metrics", SidebarIconMetrics(), currentPath, "metrics")
}
if canViewIntegration(ctx, "logs") {
@SidebarLinkWithIntegration("/logs", "Logs", SidebarIconLogs(), currentPath, "logs")
Expand Down Expand Up @@ -2332,7 +2332,7 @@ templ renderIntegrationLink(name string, currentPath string) {
case "haproxy":
@SidebarLinkDraggable("/haproxy", "HAProxy", SidebarIconHAProxy(), currentPath, "haproxy")
case "metrics":
@SidebarLinkDraggable("/history", "Metrics", SidebarIconMetrics(), currentPath, "metrics")
@SidebarLinkDraggable("/metrics", "Metrics", SidebarIconMetrics(), currentPath, "metrics")
case "logs":
@SidebarLinkDraggable("/logs", "Logs", SidebarIconLogs(), currentPath, "logs")
case "services":
Expand Down
2 changes: 1 addition & 1 deletion gearbox/internal/framework/templates/pages/details.templ
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,7 @@ templ BackendDetail(user *models.User, backendName string, stats *models.HAProxy
function loadBackendHistory() {
updateResolutionLabel();
var hours = document.getElementById('hours-select').value;
fetch('/api/' + backendServerID + '/history/backend/' + encodeURIComponent(backendNameVar) + '?hours=' + hours)
fetch('/api/' + backendServerID + '/metrics/backend/' + encodeURIComponent(backendNameVar) + '?hours=' + hours)
.then(function(response) { return response.json(); })
.then(function(result) {
cachedBackendData = result.data || [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import "github.com/sarg3nt/gearbox/internal/framework/models"
import "github.com/sarg3nt/gearbox/internal/framework/templates/layouts"
import "github.com/sarg3nt/gearbox/internal/framework/templates/components"

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

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

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

// Cache the data for fullscreen toggle
Expand Down
Loading
Loading