Skip to content

Commit ed77ce3

Browse files
sarg3ntclaude
andcommitted
feat(#103): metrics page — per-user, per-box draggable layout (GridStack)
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) <noreply@anthropic.com>
1 parent 0384a1e commit ed77ce3

6 files changed

Lines changed: 760 additions & 62 deletions

File tree

gearbox/cmd/server/main.go

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,19 @@ import (
1515
"github.com/go-chi/chi/v5"
1616
"github.com/go-chi/chi/v5/middleware"
1717
gearbox "github.com/sarg3nt/gearbox"
18-
"github.com/sarg3nt/gearbox/internal/framework/collector"
19-
"github.com/sarg3nt/gearbox/internal/framework/database"
2018
"github.com/sarg3nt/gearbox/internal/framework/auth"
19+
"github.com/sarg3nt/gearbox/internal/framework/collector"
2120
"github.com/sarg3nt/gearbox/internal/framework/config"
21+
"github.com/sarg3nt/gearbox/internal/framework/database"
2222
"github.com/sarg3nt/gearbox/internal/framework/events"
2323
"github.com/sarg3nt/gearbox/internal/framework/gear"
24+
"github.com/sarg3nt/gearbox/internal/framework/handler"
25+
gbmiddleware "github.com/sarg3nt/gearbox/internal/framework/middleware"
26+
"github.com/sarg3nt/gearbox/internal/framework/models"
2427
"github.com/sarg3nt/gearbox/internal/framework/services"
2528
"github.com/sarg3nt/gearbox/internal/framework/services/alerts"
2629
"github.com/sarg3nt/gearbox/internal/framework/services/crypto"
2730
"github.com/sarg3nt/gearbox/internal/framework/services/email"
28-
"github.com/sarg3nt/gearbox/internal/framework/handler"
29-
gbmiddleware "github.com/sarg3nt/gearbox/internal/framework/middleware"
30-
"github.com/sarg3nt/gearbox/internal/framework/models"
3131

3232
// Import gears - blank identifier triggers init() registration
3333
_ "github.com/sarg3nt/gearbox/internal/gears/alerts"
@@ -383,13 +383,13 @@ func main() {
383383
serverAdapter := services.NewServerAdapter(db, encryptor, servers, logger)
384384

385385
gearDeps := gear.Dependencies{
386-
DB: db.GetDB(), // Get the underlying *sql.DB
387-
Logger: logger,
388-
EventHub: eventsAdapter,
389-
Auth: authAdapter,
390-
Servers: serverAdapter,
391-
HTTPClient: http.DefaultClient,
392-
Config: make(map[string]any),
386+
DB: db.GetDB(), // Get the underlying *sql.DB
387+
Logger: logger,
388+
EventHub: eventsAdapter,
389+
Auth: authAdapter,
390+
Servers: serverAdapter,
391+
HTTPClient: http.DefaultClient,
392+
Config: make(map[string]any),
393393
}
394394

395395
// Create gear manager (no store for now - will add database-backed store later)
@@ -540,7 +540,7 @@ func main() {
540540
r.Group(func(r chi.Router) {
541541
r.Use(authManager.RequireAuth)
542542
r.Use(authManager.RequirePasswordChange) // Enforce password change before any other action
543-
r.Use(h.InjectIntegrationStatus) // Add integration status to context for sidebar rendering
543+
r.Use(h.InjectIntegrationStatus) // Add integration status to context for sidebar rendering
544544
r.Use(middleware.Timeout(60 * time.Second))
545545

546546
// Logout
@@ -704,6 +704,14 @@ func main() {
704704
r.Get("/{boxID}/metrics/source/{source}/summary", h.APIMetricsSourceSummaryHandler)
705705
r.Get("/{boxID}/metrics/source/{source}/log-errors", h.APIMetricsSourceLogErrorsHandler)
706706

707+
// Per-user, per-box layout (issue #103). GET returns
708+
// the user's saved GridStack positions for this box
709+
// (or 204 = "use template defaults"); PATCH upserts;
710+
// DELETE drops the saved row to reset to defaults.
711+
r.Get("/{boxID}/metrics/layout", h.APIMetricsLayoutGetHandler)
712+
r.Patch("/{boxID}/metrics/layout", h.APIMetricsLayoutPatchHandler)
713+
r.Delete("/{boxID}/metrics/layout", h.APIMetricsLayoutDeleteHandler)
714+
707715
// Per-box capability manifest — exposes which agent gears probed
708716
// available so the metrics gear (and future source-aware UI) can
709717
// hide cards/KPIs that don't apply to this host.

gearbox/internal/framework/database/database.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ func New(dbPath string, logger *slog.Logger) (*DB, error) {
102102
return nil, fmt.Errorf("failed to initialize home schema: %w", err)
103103
}
104104

105+
// Initialize metrics layouts (per-user, per-box GridStack
106+
// positions for the metrics page — see issue #103).
107+
if err := d.initMetricsLayoutsSchema(); err != nil {
108+
return nil, fmt.Errorf("failed to initialize metrics layouts schema: %w", err)
109+
}
110+
105111
// Run schema migrations AFTER all schemas are initialized
106112
// (migrations may reference tables from any schema)
107113
if err := d.runSchemaMigrations(); err != nil {
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Package database — per-user, per-box layout persistence for the
2+
// Metrics gear's GridStack-driven page (issue #103).
3+
//
4+
// The Metrics page renders a fixed set of chart cards (HAProxy
5+
// stats, host metrics, plus capability-gated per-source cards).
6+
// Operators rearrange/resize those cards in edit mode; the saved
7+
// layout lives here. Storage shape: one JSON blob per (user, box)
8+
// holding GridStack's `save()` output verbatim — array of
9+
// `{id, x, y, w, h}` objects keyed by the stable tile DOM id
10+
// (card-cpu, card-memory, card-nginx, …). JSON beats per-tile rows
11+
// because the dashboard always reads/writes the whole layout at
12+
// once and there's no querying inside it; the JSON column keeps
13+
// the schema additive when we add tile shapes later.
14+
package database
15+
16+
import (
17+
"database/sql"
18+
"errors"
19+
"fmt"
20+
"time"
21+
)
22+
23+
// MetricsLayout is one persisted layout — the GridStack `save()`
24+
// payload kept verbatim plus the (user, server) coordinates it
25+
// belongs to.
26+
//
27+
// Layout is stored as []byte rather than a typed struct because
28+
// GridStack's save format is the canonical form here: every field
29+
// it emits round-trips back through `load()` unchanged. Typing it
30+
// would mean keeping our Go shape in lockstep with GridStack's
31+
// minor-version changes — not worth the maintenance cost for a
32+
// JSON blob the dashboard never inspects.
33+
type MetricsLayout struct {
34+
UserID string `json:"user_id"`
35+
ServerID string `json:"server_id"`
36+
Layout []byte `json:"layout"`
37+
UpdatedAt time.Time `json:"updated_at"`
38+
}
39+
40+
// ErrNoMetricsLayout is returned by GetMetricsLayout when no row
41+
// exists for (userID, serverID). Callers translate this into "use
42+
// the default layout from the page template" rather than an HTTP
43+
// 500 — first-visit users hit this path every time.
44+
var ErrNoMetricsLayout = errors.New("no metrics_layouts row for the user/server pair")
45+
46+
// initMetricsLayoutsSchema creates the per-user-per-box layout
47+
// table. Called from initSchema during database startup, alongside
48+
// the other gear schemas. No foreign keys to users(id) intentionally:
49+
// if a user is deleted we'd rather orphan their saved layouts than
50+
// take an FK cascade — the orphans are tiny (one JSON blob each) and
51+
// the simpler schema sidesteps a delete-cascade ordering question.
52+
//
53+
// (server_id is text because the dashboard's box / server addressing
54+
// is string-keyed — server_id matches the convention used by
55+
// stats_history, traffic_flows, etc.)
56+
func (d *DB) initMetricsLayoutsSchema() error {
57+
schema := `
58+
CREATE TABLE IF NOT EXISTS metrics_layouts (
59+
user_id TEXT NOT NULL,
60+
server_id TEXT NOT NULL,
61+
layout_json TEXT NOT NULL,
62+
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
63+
PRIMARY KEY (user_id, server_id)
64+
);
65+
66+
CREATE INDEX IF NOT EXISTS idx_metrics_layouts_user
67+
ON metrics_layouts(user_id);
68+
`
69+
_, err := d.db.Exec(schema)
70+
return err
71+
}
72+
73+
// GetMetricsLayout returns the saved layout for one (user, box) pair.
74+
// Returns ErrNoMetricsLayout when nothing's saved yet — callers
75+
// (the handler) translate to "render the default layout from the
76+
// page template" so first visits work without an extra round trip.
77+
func (d *DB) GetMetricsLayout(userID string, serverID string) (*MetricsLayout, error) {
78+
d.mu.RLock()
79+
defer d.mu.RUnlock()
80+
81+
row := d.db.QueryRow(`
82+
SELECT user_id, server_id, layout_json, updated_at
83+
FROM metrics_layouts
84+
WHERE user_id = ? AND server_id = ?`,
85+
userID, serverID,
86+
)
87+
var (
88+
out MetricsLayout
89+
layout string
90+
)
91+
if err := row.Scan(&out.UserID, &out.ServerID, &layout, &out.UpdatedAt); err != nil {
92+
if errors.Is(err, sql.ErrNoRows) {
93+
return nil, ErrNoMetricsLayout
94+
}
95+
return nil, fmt.Errorf("scan metrics_layouts: %w", err)
96+
}
97+
out.Layout = []byte(layout)
98+
return &out, nil
99+
}
100+
101+
// SaveMetricsLayout upserts the layout for one (user, box) pair.
102+
// `layoutJSON` must already be a valid JSON string — the handler
103+
// is responsible for shape-checking before calling. We don't
104+
// re-validate here because the canonical schema lives in
105+
// GridStack's JS, and our agnostic-blob approach intentionally
106+
// avoids tracking that schema in Go.
107+
//
108+
// updated_at is bumped server-side so the dashboard can show
109+
// "last edited X minutes ago" without trusting client clocks.
110+
func (d *DB) SaveMetricsLayout(userID string, serverID string, layoutJSON []byte) error {
111+
d.mu.Lock()
112+
defer d.mu.Unlock()
113+
114+
_, err := d.db.Exec(`
115+
INSERT INTO metrics_layouts (user_id, server_id, layout_json, updated_at)
116+
VALUES (?, ?, ?, ?)
117+
ON CONFLICT(user_id, server_id) DO UPDATE SET
118+
layout_json = excluded.layout_json,
119+
updated_at = excluded.updated_at`,
120+
userID, serverID, string(layoutJSON), time.Now().UTC(),
121+
)
122+
return err
123+
}
124+
125+
// DeleteMetricsLayout removes the saved layout for one (user, box)
126+
// pair, effectively reverting the user's view to the template
127+
// default on next render. Used by the "reset to default" button in
128+
// the edit-mode toolbar.
129+
func (d *DB) DeleteMetricsLayout(userID string, serverID string) error {
130+
d.mu.Lock()
131+
defer d.mu.Unlock()
132+
133+
_, err := d.db.Exec(`
134+
DELETE FROM metrics_layouts
135+
WHERE user_id = ? AND server_id = ?`,
136+
userID, serverID,
137+
)
138+
return err
139+
}

0 commit comments

Comments
 (0)