From ed77ce3f2669e5bc0e39381d8774b6298207ccac Mon Sep 17 00:00:00 2001 From: Dave Sargent Date: Thu, 14 May 2026 23:23:07 -0700 Subject: [PATCH 01/15] =?UTF-8?q?feat(#103):=20metrics=20page=20=E2=80=94?= =?UTF-8?q?=20per-user,=20per-box=20draggable=20layout=20(GridStack)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #103. The Metrics page's chart grid becomes drag + resize editable, with the layout persisted per (user, box) so each operator gets their own view per box. Reuses Home gear's GridStack vendor bundle — no new dependency. DB (new metrics_layouts table): - Primary key (user_id, server_id) → layout_json blob. JSON beats per-tile rows because every read/write is the whole layout and there's no querying inside it. The blob is GridStack's save() output verbatim. - New file: internal/framework/database/metrics_layouts.go with GetMetricsLayout / SaveMetricsLayout / DeleteMetricsLayout + ErrNoMetricsLayout sentinel for the "no row" case. - Schema wired into database.New() right after the Home schema. Handler + routes: - New file: internal/framework/handler/api_metrics_layout.go. - GET /api/{boxID}/metrics/layout — returns saved JSON or 204 (caller falls back to template defaults; mid-rollout-safe). - PATCH /api/{boxID}/metrics/layout — upsert. Body validated as a JSON array of {id,x,y,w,h}; capped at 16 KiB. Tile IDs aren't re-validated against the known set because the dashboard silently ignores unknown ids — keeps the storage layer agnostic to GridStack version changes. - DELETE /api/{boxID}/metrics/layout — drops the row so the next load uses defaults. Drives the "Reset layout" button. - Routes registered in cmd/server/main.go alongside the existing /metrics endpoints. Templ (metrics.templ): - Wrapper panel removed; each chart card now sits inside a .grid-stack-item with default gs-x / gs-y / gs-w / gs-h attributes. Card order rearranged to the layout proposed in #103: CPU/Memory · Network/Response Time · Health/Errors · Sessions & Requests (full-width) · per-source rows. - Edit toggle button (.metrics-edit-off-label / .metrics-edit- on-label) + Reset button (.metrics-edit-only class, hidden outside edit mode) added to the controls bar. Mirrors Home's edit-toggle UX. - GridStack vendor CSS loaded inline; vendor JS + new metrics-layout.js script tags added to the page bottom (defer). - applyCapabilities() now dispatches a 'metrics:capabilities- applied' CustomEvent on every run so metrics-layout.js can reflow GridStack without either script knowing about the other's globals. JS (new static/js/metrics-layout.js): - Initialises GridStack on #charts-grid (read-only by default). - Loads saved layout via GET; 204 falls back to template defaults so first-visit users see the documented order without an endpoint round-trip's worth of flicker. - Edit-mode toggle calls setStatic(false), shows .metrics-edit- only controls, and persists on exit. PATCH is debounced so a multi-tile drag flush ends in one save call, not N. - Capability-driven reflow: listens for the CustomEvent above and calls gs.removeWidget(item, false) / gs.makeWidget(item) per-tile based on each card's .hidden class. Unavailable sources drop out of the grid (other tiles compact up) and return to their saved positions when the source reappears. - Reset button DELETEs the row and reloads, restoring template defaults. Out of scope (deferred): - Layout import/export. Single (user, box) layouts only — bulk ops can land as a follow-up when multi-box editing is a thing. - Sharing layouts between users. - Mobile-specific layouts. GridStack auto-compacts on narrow viewports — same behaviour Home relies on, no metrics-specific treatment needed. go build / go vet / go test / templ generate all clean. ~580 LOC new code (DB + handler + JS), plus templ edits across the page. Co-Authored-By: Claude Opus 4.7 (1M context) --- gearbox/cmd/server/main.go | 34 ++- .../internal/framework/database/database.go | 6 + .../framework/database/metrics_layouts.go | 139 +++++++++ .../framework/handler/api_metrics_layout.go | 177 ++++++++++++ .../framework/templates/pages/metrics.templ | 203 ++++++++++---- gearbox/static/js/metrics-layout.js | 263 ++++++++++++++++++ 6 files changed, 760 insertions(+), 62 deletions(-) create mode 100644 gearbox/internal/framework/database/metrics_layouts.go create mode 100644 gearbox/internal/framework/handler/api_metrics_layout.go create mode 100644 gearbox/static/js/metrics-layout.js diff --git a/gearbox/cmd/server/main.go b/gearbox/cmd/server/main.go index e37bffc..afb1680 100644 --- a/gearbox/cmd/server/main.go +++ b/gearbox/cmd/server/main.go @@ -15,19 +15,19 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" gearbox "github.com/sarg3nt/gearbox" - "github.com/sarg3nt/gearbox/internal/framework/collector" - "github.com/sarg3nt/gearbox/internal/framework/database" "github.com/sarg3nt/gearbox/internal/framework/auth" + "github.com/sarg3nt/gearbox/internal/framework/collector" "github.com/sarg3nt/gearbox/internal/framework/config" + "github.com/sarg3nt/gearbox/internal/framework/database" "github.com/sarg3nt/gearbox/internal/framework/events" "github.com/sarg3nt/gearbox/internal/framework/gear" + "github.com/sarg3nt/gearbox/internal/framework/handler" + gbmiddleware "github.com/sarg3nt/gearbox/internal/framework/middleware" + "github.com/sarg3nt/gearbox/internal/framework/models" "github.com/sarg3nt/gearbox/internal/framework/services" "github.com/sarg3nt/gearbox/internal/framework/services/alerts" "github.com/sarg3nt/gearbox/internal/framework/services/crypto" "github.com/sarg3nt/gearbox/internal/framework/services/email" - "github.com/sarg3nt/gearbox/internal/framework/handler" - gbmiddleware "github.com/sarg3nt/gearbox/internal/framework/middleware" - "github.com/sarg3nt/gearbox/internal/framework/models" // Import gears - blank identifier triggers init() registration _ "github.com/sarg3nt/gearbox/internal/gears/alerts" @@ -383,13 +383,13 @@ func main() { serverAdapter := services.NewServerAdapter(db, encryptor, servers, logger) gearDeps := gear.Dependencies{ - DB: db.GetDB(), // Get the underlying *sql.DB - Logger: logger, - EventHub: eventsAdapter, - Auth: authAdapter, - Servers: serverAdapter, - HTTPClient: http.DefaultClient, - Config: make(map[string]any), + DB: db.GetDB(), // Get the underlying *sql.DB + Logger: logger, + EventHub: eventsAdapter, + Auth: authAdapter, + Servers: serverAdapter, + HTTPClient: http.DefaultClient, + Config: make(map[string]any), } // Create gear manager (no store for now - will add database-backed store later) @@ -540,7 +540,7 @@ func main() { r.Group(func(r chi.Router) { r.Use(authManager.RequireAuth) r.Use(authManager.RequirePasswordChange) // Enforce password change before any other action - r.Use(h.InjectIntegrationStatus) // Add integration status to context for sidebar rendering + r.Use(h.InjectIntegrationStatus) // Add integration status to context for sidebar rendering r.Use(middleware.Timeout(60 * time.Second)) // Logout @@ -704,6 +704,14 @@ func main() { r.Get("/{boxID}/metrics/source/{source}/summary", h.APIMetricsSourceSummaryHandler) r.Get("/{boxID}/metrics/source/{source}/log-errors", h.APIMetricsSourceLogErrorsHandler) + // Per-user, per-box layout (issue #103). GET returns + // the user's saved GridStack positions for this box + // (or 204 = "use template defaults"); PATCH upserts; + // DELETE drops the saved row to reset to defaults. + r.Get("/{boxID}/metrics/layout", h.APIMetricsLayoutGetHandler) + r.Patch("/{boxID}/metrics/layout", h.APIMetricsLayoutPatchHandler) + r.Delete("/{boxID}/metrics/layout", h.APIMetricsLayoutDeleteHandler) + // Per-box capability manifest — exposes which agent gears probed // available so the metrics gear (and future source-aware UI) can // hide cards/KPIs that don't apply to this host. diff --git a/gearbox/internal/framework/database/database.go b/gearbox/internal/framework/database/database.go index e3ea3f0..446a480 100644 --- a/gearbox/internal/framework/database/database.go +++ b/gearbox/internal/framework/database/database.go @@ -102,6 +102,12 @@ func New(dbPath string, logger *slog.Logger) (*DB, error) { return nil, fmt.Errorf("failed to initialize home schema: %w", err) } + // Initialize metrics layouts (per-user, per-box GridStack + // positions for the metrics page — see issue #103). + if err := d.initMetricsLayoutsSchema(); err != nil { + return nil, fmt.Errorf("failed to initialize metrics layouts schema: %w", err) + } + // Run schema migrations AFTER all schemas are initialized // (migrations may reference tables from any schema) if err := d.runSchemaMigrations(); err != nil { diff --git a/gearbox/internal/framework/database/metrics_layouts.go b/gearbox/internal/framework/database/metrics_layouts.go new file mode 100644 index 0000000..01d3e5e --- /dev/null +++ b/gearbox/internal/framework/database/metrics_layouts.go @@ -0,0 +1,139 @@ +// Package database — per-user, per-box layout persistence for the +// Metrics gear's GridStack-driven page (issue #103). +// +// The Metrics page renders a fixed set of chart cards (HAProxy +// stats, host metrics, plus capability-gated per-source cards). +// Operators rearrange/resize those cards in edit mode; the saved +// layout lives here. Storage shape: one JSON blob per (user, box) +// holding GridStack's `save()` output verbatim — array of +// `{id, x, y, w, h}` objects keyed by the stable tile DOM id +// (card-cpu, card-memory, card-nginx, …). JSON beats per-tile rows +// because the dashboard always reads/writes the whole layout at +// once and there's no querying inside it; the JSON column keeps +// the schema additive when we add tile shapes later. +package database + +import ( + "database/sql" + "errors" + "fmt" + "time" +) + +// MetricsLayout is one persisted layout — the GridStack `save()` +// payload kept verbatim plus the (user, server) coordinates it +// belongs to. +// +// Layout is stored as []byte rather than a typed struct because +// GridStack's save format is the canonical form here: every field +// it emits round-trips back through `load()` unchanged. Typing it +// would mean keeping our Go shape in lockstep with GridStack's +// minor-version changes — not worth the maintenance cost for a +// JSON blob the dashboard never inspects. +type MetricsLayout struct { + UserID string `json:"user_id"` + ServerID string `json:"server_id"` + Layout []byte `json:"layout"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ErrNoMetricsLayout is returned by GetMetricsLayout when no row +// exists for (userID, serverID). Callers translate this into "use +// the default layout from the page template" rather than an HTTP +// 500 — first-visit users hit this path every time. +var ErrNoMetricsLayout = errors.New("no metrics_layouts row for the user/server pair") + +// initMetricsLayoutsSchema creates the per-user-per-box layout +// table. Called from initSchema during database startup, alongside +// the other gear schemas. No foreign keys to users(id) intentionally: +// if a user is deleted we'd rather orphan their saved layouts than +// take an FK cascade — the orphans are tiny (one JSON blob each) and +// the simpler schema sidesteps a delete-cascade ordering question. +// +// (server_id is text because the dashboard's box / server addressing +// is string-keyed — server_id matches the convention used by +// stats_history, traffic_flows, etc.) +func (d *DB) initMetricsLayoutsSchema() error { + schema := ` + CREATE TABLE IF NOT EXISTS metrics_layouts ( + user_id TEXT NOT NULL, + server_id TEXT NOT NULL, + layout_json TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, server_id) + ); + + CREATE INDEX IF NOT EXISTS idx_metrics_layouts_user + ON metrics_layouts(user_id); + ` + _, err := d.db.Exec(schema) + return err +} + +// GetMetricsLayout returns the saved layout for one (user, box) pair. +// Returns ErrNoMetricsLayout when nothing's saved yet — callers +// (the handler) translate to "render the default layout from the +// page template" so first visits work without an extra round trip. +func (d *DB) GetMetricsLayout(userID string, serverID string) (*MetricsLayout, error) { + d.mu.RLock() + defer d.mu.RUnlock() + + row := d.db.QueryRow(` + SELECT user_id, server_id, layout_json, updated_at + FROM metrics_layouts + WHERE user_id = ? AND server_id = ?`, + userID, serverID, + ) + var ( + out MetricsLayout + layout string + ) + if err := row.Scan(&out.UserID, &out.ServerID, &layout, &out.UpdatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNoMetricsLayout + } + return nil, fmt.Errorf("scan metrics_layouts: %w", err) + } + out.Layout = []byte(layout) + return &out, nil +} + +// SaveMetricsLayout upserts the layout for one (user, box) pair. +// `layoutJSON` must already be a valid JSON string — the handler +// is responsible for shape-checking before calling. We don't +// re-validate here because the canonical schema lives in +// GridStack's JS, and our agnostic-blob approach intentionally +// avoids tracking that schema in Go. +// +// updated_at is bumped server-side so the dashboard can show +// "last edited X minutes ago" without trusting client clocks. +func (d *DB) SaveMetricsLayout(userID string, serverID string, layoutJSON []byte) error { + d.mu.Lock() + defer d.mu.Unlock() + + _, err := d.db.Exec(` + INSERT INTO metrics_layouts (user_id, server_id, layout_json, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, server_id) DO UPDATE SET + layout_json = excluded.layout_json, + updated_at = excluded.updated_at`, + userID, serverID, string(layoutJSON), time.Now().UTC(), + ) + return err +} + +// DeleteMetricsLayout removes the saved layout for one (user, box) +// pair, effectively reverting the user's view to the template +// default on next render. Used by the "reset to default" button in +// the edit-mode toolbar. +func (d *DB) DeleteMetricsLayout(userID string, serverID string) error { + d.mu.Lock() + defer d.mu.Unlock() + + _, err := d.db.Exec(` + DELETE FROM metrics_layouts + WHERE user_id = ? AND server_id = ?`, + userID, serverID, + ) + return err +} diff --git a/gearbox/internal/framework/handler/api_metrics_layout.go b/gearbox/internal/framework/handler/api_metrics_layout.go new file mode 100644 index 0000000..2650c80 --- /dev/null +++ b/gearbox/internal/framework/handler/api_metrics_layout.go @@ -0,0 +1,177 @@ +// Package handler — per-user, per-box layout endpoints for the +// Metrics gear's draggable page (issue #103). +// +// Two endpoints live here: +// +// GET /api/{boxID}/metrics/layout +// → returns the user's saved layout, or 204 No Content when +// no row exists (caller renders the template default). +// +// PATCH /api/{boxID}/metrics/layout +// → upserts the layout from a JSON body. Body is GridStack's +// `save()` output verbatim, validated only as well-formed +// JSON + a tile-shape sanity check. +// +// A DELETE variant powers "reset to default" — drops the saved row +// so the next GET 204s and the page falls back to its template +// default. +// +// All three require `metrics:view` permission (the same scope the +// metrics page itself uses); we don't have a separate "edit layout" +// scope because the persisted layout is per-user, so a user can +// only ever edit their own view. +package handler + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/sarg3nt/gearbox/internal/framework/database" + "github.com/sarg3nt/gearbox/internal/framework/models" +) + +// maxLayoutBytes caps the request body size so a runaway client +// can't fill the database with megabyte-sized JSON. Real GridStack +// payloads for the metrics page are ~11 tiles × ~50 bytes/tile + +// envelope = ~1 KB; 16 KB is generous and matches what other +// dashboard PATCH endpoints accept. +const maxLayoutBytes = 16 * 1024 + +// layoutTile is the minimum shape we require each entry in the +// posted layout array to carry. GridStack's `save()` includes +// these four fields for every node; the `id` is the stable DOM id +// of the tile (e.g. "card-cpu"). We don't enforce the id's value +// against the known set of cards — the dashboard renders cards by +// id and ignores anything it doesn't recognise, so an unknown id +// in the saved layout is a no-op at render time rather than a +// failure mode worth rejecting here. +type layoutTile struct { + ID string `json:"id"` + X int `json:"x"` + Y int `json:"y"` + W int `json:"w"` + H int `json:"h"` +} + +// APIMetricsLayoutGetHandler returns the user's saved metrics +// layout for one box. Returns 204 No Content when nothing's saved +// — that's the signal for the front-end to use the template's +// default tile positions rather than the empty grid. +func (h *Handler) APIMetricsLayoutGetHandler(w http.ResponseWriter, r *http.Request) { + if !h.authManager.HasPermission(r, models.ComponentMetrics, models.PermissionView) { + http.Error(w, "Forbidden: insufficient permissions to view metrics", http.StatusForbidden) + return + } + user, err := h.authManager.GetUser(r) + if err != nil || user == nil { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + boxID := chi.URLParam(r, "boxID") + if boxID == "" { + http.Error(w, "Server ID required", http.StatusBadRequest) + return + } + + layout, err := h.db.GetMetricsLayout(user.ID, boxID) + if err != nil { + if errors.Is(err, database.ErrNoMetricsLayout) { + // Empty response — front-end falls back to defaults. + // 204 is the right code: success, no body to return. + w.WriteHeader(http.StatusNoContent) + return + } + h.logger.Error("metrics layout get", "user_id", user.ID, "server_id", boxID, "error", err) + http.Error(w, "Failed to load layout", http.StatusInternalServerError) + return + } + + // Pass the JSON blob through verbatim — we never re-parse it + // on the way out (it round-trips back into GridStack.load() + // on the client). + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(layout.Layout) +} + +// APIMetricsLayoutPatchHandler upserts the layout JSON the +// dashboard sent. The body must decode as a JSON array of tile +// objects (see layoutTile); anything else is rejected as 400 +// rather than persisted, so the GET path can trust the stored +// blob is shape-valid. +func (h *Handler) APIMetricsLayoutPatchHandler(w http.ResponseWriter, r *http.Request) { + if !h.authManager.HasPermission(r, models.ComponentMetrics, models.PermissionView) { + http.Error(w, "Forbidden: insufficient permissions to view metrics", http.StatusForbidden) + return + } + user, err := h.authManager.GetUser(r) + if err != nil || user == nil { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + boxID := chi.URLParam(r, "boxID") + if boxID == "" { + http.Error(w, "Server ID required", http.StatusBadRequest) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, maxLayoutBytes+1)) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + if len(body) > maxLayoutBytes { + http.Error(w, "Layout body too large", http.StatusRequestEntityTooLarge) + return + } + + // Shape-validate: array of tile objects with at minimum the + // four GridStack fields. We don't reject extra keys — the + // agnostic-blob design wants newer GridStack versions' extra + // fields to round-trip without a Go change. + var tiles []layoutTile + if err := json.Unmarshal(body, &tiles); err != nil { + http.Error(w, "Invalid layout JSON: "+err.Error(), http.StatusBadRequest) + return + } + if len(tiles) == 0 { + http.Error(w, "Layout must contain at least one tile", http.StatusBadRequest) + return + } + + if err := h.db.SaveMetricsLayout(user.ID, boxID, body); err != nil { + h.logger.Error("metrics layout save", "user_id", user.ID, "server_id", boxID, "error", err) + http.Error(w, "Failed to save layout", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// APIMetricsLayoutDeleteHandler drops the user's saved layout for +// one box. The next GET 204s and the page renders defaults — the +// "reset to default" button in the edit-mode toolbar hits this. +func (h *Handler) APIMetricsLayoutDeleteHandler(w http.ResponseWriter, r *http.Request) { + if !h.authManager.HasPermission(r, models.ComponentMetrics, models.PermissionView) { + http.Error(w, "Forbidden: insufficient permissions to view metrics", http.StatusForbidden) + return + } + user, err := h.authManager.GetUser(r) + if err != nil || user == nil { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + boxID := chi.URLParam(r, "boxID") + if boxID == "" { + http.Error(w, "Server ID required", http.StatusBadRequest) + return + } + + if err := h.db.DeleteMetricsLayout(user.ID, boxID); err != nil { + h.logger.Error("metrics layout delete", "user_id", user.ID, "server_id", boxID, "error", err) + http.Error(w, "Failed to delete layout", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/gearbox/internal/framework/templates/pages/metrics.templ b/gearbox/internal/framework/templates/pages/metrics.templ index e0ff1bc..d2112ce 100644 --- a/gearbox/internal/framework/templates/pages/metrics.templ +++ b/gearbox/internal/framework/templates/pages/metrics.templ @@ -62,6 +62,35 @@ templ Metrics(user *models.User, servers []models.BoxConfig) { + + + + + + @@ -93,122 +122,156 @@ templ Metrics(user *models.User, servers []models.BoxConfig) { } -
- - -
- -
+ +
+ +
+
-

HAProxy: Sessions & Requests

-
- +
+
- -
+ +
+
-

HAProxy: Server Health

-
- +
+
- -
+ +
+
-

Host: CPU Load

-
- +
+
- -
+ +
+
-

Host: Memory Usage

-
- +
+
- -
+ +
+
-

Host: Network Throughput

-
- +
+
- -
+ +
+
-

HAProxy: Response Times

-
- +
+
- -
+ +
+
-

HAProxy: Error Rates (5xx)

-
- +
+
- -