Skip to content

Commit 48f6c7a

Browse files
sarg3ntclaude
andauthored
feat(#103): metrics page — per-user, per-box draggable layout (GridStack) (#104)
* 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> * fix(#103): address Copilot review findings on PR #104 JS: - metrics-layout.js: getServerID was missing the single-server fallback (#default-server-id) that metrics.templ's inline helper consults. Single-box installs silently no-op'd every layout GET/PATCH/DELETE because serverID came back empty. Now matches the inline helper byte-for-byte: select → default-server-id → URL. - metrics-layout.js: saveLayout now takes a {flush: true} option used by the edit-mode exit handler. Without flush, a tab-close right after "Done editing" could drop the layout because the debounce was still pending. Drag/resize calls still use the debounced path so a multi-tile drag flushes once, not N times. - metrics-layout.js: resetLayout now cancels any pending debounced PATCH AND suppresses further saves before issuing the DELETE. Without this a fast drag-then-reset race could land a PATCH after the DELETE and resurrect the row the user just threw away. - metrics-layout.js: GridStack margin 8 → 16. Comment had claimed 16 (matches Tailwind gap-4 and Home's gridstack init); value was the typo. Matched to Home now so the visual idiom is consistent across pages. Handler: - api_metrics_layout.go: PATCH now runs validateLayoutTiles before persisting. Previously a malformed client could land tiles with empty IDs, negative coords, zero/negative dimensions, or duplicate IDs that would silently corrupt the saved blob. New validator enforces: non-empty id, x/y in [0, maxCoord], w/h in [1, maxDim], unique ids across the array, tile count in [1, maxTilesPerLayout=64]. First failure returned with an actionable message (one good error beats a list when the source is a JS caller, not a hand-edited file). - api_metrics_layout_test.go (new): table-driven coverage of every validateLayoutTiles rejection path plus the happy path. - metrics_layouts_test.go (new): DB round-trip coverage for GetMetricsLayout (empty → ErrNoMetricsLayout sentinel), Save upsert behaviour, isolation across (user, box) pairs, Delete resets-to-default flow, Delete no-op on absent row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#103): chart canvases overflowing GridStack tiles The chart-aspect-container's padding-bottom: 72% trick sizes the inner canvas from the parent's width — which worked under the old flex grid (every card was the same shape, height matched width naturally) but breaks under GridStack: the tile's height is gs-h × cellHeight (320px on a default tile), independent of the tile's width. Where width × 72% > 320px, the canvas overflowed the tile. Visible result on a typical viewport: legends + time-axis labels bleeding out of every chart card into the rows below them (screenshot in the PR thread). Fix: scope `.chart-aspect-container` so it behaves as a flex-fill container inside the GridStack grid: - #charts-grid .grid-stack-item-content → display: flex, flex-direction: column, height: 100%, overflow: hidden so the chart container can flex to the GridStack-set height and stragglers can't escape during resize transitions. - #charts-grid .chart-aspect-container → flex: 1 1 auto, min-height: 0, padding-bottom: 0. min-height: 0 is the flex-child idiom that lets the container shrink below its content's intrinsic size — without it, the chart refused to render below ~432px. - The original aspect-ratio rule stays in place for usages outside the metrics grid (drill-down drawer, etc.). The canvas absolute-fill rule (width/height: 100% !important) was already correct; it just needed a parent with a real flex- computed height to fill. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#103): chart-grid container height + bottom spacing Two issues from PR #104 manual testing: 1. The GridStack container's auto-height tracking lagged the actual tile layout when capability-driven removeWidget() calls ran during init. Result: whatever section came after the grid (Error Insights panel) was rendering on top of the bottom row of tiles because the grid's height didn't reflect them. Fix: compute min-height manually as max(y+h) * cellHeight and set it on the container after every load / capability flip / layout change. GridStack's own auto-sizing still runs as normal — this is belt + suspenders for the staticGrid + removeWidget combination where the engine's height update was racing the visible layout. 2. No bottom margin between the grid and the next section. Fix: add mb-6 to #charts-grid so it pushes following content down by the same 24px gap that separates the KPI band from the grid above. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#103): capability-hidden tiles staying visible (CSS specificity bug) Root cause: my new flex-fill rule #charts-grid .grid-stack-item-content { display: flex; ... } has higher specificity (0,1,2,0) than Tailwind's .hidden utility (0,0,1,0) and was winning the cascade. So when applyCapabilities() correctly added .hidden to capability-not-installed cards (caddy / traefik / nginx / apache on a HAProxy-only host like light-hugger), the display: flex from my rule overrode display: none from .hidden — the cards stayed visible with their titles + empty chart areas. The agent's probes and the JS hide logic were both doing the right thing the whole time; the override was silent because dev-tools' computed-style panel just shows "display: flex" without flagging which rule it lost to. Fix: scope the flex-fill rule with :not(.hidden) so .hidden wins the cascade for capability-hidden cards. Now display: none kicks in as intended, the tile vanishes visually, and the container- height recompute brings the bottom edge up to whatever the last *visible* tile ends — no more ghost slots reserving space below the layout. Same bug would have hit no-HAProxy hosts (the HAProxy cards would stay visible despite haproxyAvailable=false). Catching it before that footgun lands in front of a real user. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#103): bump GridStack margin to 24 for vertical breathing room 16px between rows blended together in dark mode — the card backgrounds and the page background are similar enough greys that the gap visually disappeared, especially between Server Health / Error Rates and the full-width Sessions & Requests tile below. 24 (12px around every tile → 24px between neighbours) gives the rows a visible separation without spreading the grid wide enough to hurt density on smaller displays. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#103): asymmetric GridStack margins (taller rows, same columns) A uniform margin applied the same gap horizontally and vertically. With charts having axis labels stacked top + bottom of each tile, the visual content of adjacent rows ends up closer together than adjacent columns — so a single margin value that looked spacious horizontally felt cramped vertically (rows visibly merged together under the full-width Sessions & Requests tile). GridStack supports four directional margins. Going with: marginTop / marginBottom: 18 → 36px between rows marginLeft / marginRight: 12 → 24px between side-by-side (The dev-tools "outer frame doesn't match inner content" the user noticed is GridStack's design — the outer holds the grid slot and the inner is inset by margin/2 on each edge. With asymmetric margins the inset is asymmetric too, which is correct.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#103): GridStack cellHeight() side-effects + bump vertical margins Two interlocking issues that together produced the merged-row look. 1. GridStack's cellHeight() (the bare getter, no args) is actually an IMPLICIT SETTER: it recomputes opts.cellHeight as cellWidth + (marginTop+marginBottom - marginLeft-marginRight) whenever called, and regenerates the per-y/per-h CSS rules with the new value. My updateContainerHeight() called it on every load / capability flip / drag, so opts.cellHeight kept drifting away from the configured 80 toward whatever the current container width yielded. That's why bumping margins never landed exactly where the math predicted — every margin change shifted the cellWidth-derived cellHeight as a side effect. Fix: use getCellHeight(false). Same return value, no setter side effect, no CSS-rule regeneration. 2. With the cellHeight finally stable at 80, the rows still felt tight at the prior 18 top/bottom margin. Bumped to 24 each (48px between rows) which lines up with what the user sketched as "Better" in the dev-tools screenshot (top: 990 for the y=12 tile = 12*80 + 24/2 + 24/2 in the new math). Horizontal margins stay at 12 each (24px between side-by-side) because that gap already looked right. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#103): remove height:100% from inner card — was eating the row gap Diagnosed via Chrome MCP direct DOM inspection. Three rules were fighting over the inner card's size: GridStack vendor CSS → position: absolute; top: 24px; bottom: 24px My rule on #charts-grid → height: 100% CSS spec when over-constrained → height wins Resolved: inner ended up 320px tall (matching outer's full height) anchored at top:24, so it extended 24px BELOW the outer's bottom — exactly where the next row's outer begins. Visual result: zero gap between adjacent rows' inner cards, no matter what margin value I set on the GridStack init. That's why margins 18, 24, 36 all looked identical — the cells were always butting up against each other. Fix: drop the `height: 100%`. GridStack's inline top + bottom is sufficient to size the absolute-positioned inner content (inner height becomes outer_height − marginTop − marginBottom = 272px for gs-h=4, leaving a 48px gap between rows that matches the configured margins). Verified via Chrome MCP after fix: inner height: 272 (was 320, was overrunning by 24) visible gap between row 0 inner bottom and row 4 inner top: 48px Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#103): match chart-grid spacing to the KPI band (12px both axes) After the previous height:100% fix exposed the real margin spacing, 48px vertical felt too generous compared to the KPI band's tight 12px gap-3 grid directly above. Drop GridStack margins to 6 on every side → 12px gap on both axes, matching the KPI band exactly so the whole metrics page reads as one consistently-spaced surface. Verified via Chrome MCP after fix: horizontal gap between adjacent inner cards: 12px vertical gap between adjacent inner cards: 12px Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#103): align chart grid with KPI band (top gap + outer edges) The KPI band has no outer inset on its cards (Tailwind grid + gap-3), so its leftmost card sits flush with the page-content edge. The chart grid below was 6px inset on left/right and 22px below the KPI band (16px from KPI's mb-4 + 6px GridStack first-row inset). The result was a 6px shift right + a too-large vertical gap that broke the consistent 12px rhythm. Fix: pull #charts-grid outward with negative margins that absorb GridStack's outer inset: margin-left: -6 → first column visible-left aligns with KPI margin-right: -6 → last column visible-right aligns with KPI margin-top: -10 → 16 (KPI mb-4) - 10 + 6 (gs marginTop) = 12px gap Verified via Chrome MCP: kpiContainer.left = chart first-inner.left (280 = 280) kpiContainer.right = chart last-inner.right (1428.5 = 1428.5) topGap = 12 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(#103): standardise gear edit/settings affordance on a cog icon Replace the Home and Metrics gears' Edit toggles with an icon-only cog button anchored at the top-right of each page's header. Same SVG path as the main sidebar Settings cog so the visual vocabulary stays consistent and a future contributor reading either gear sees the same affordance. Shared styling in static/css/components/buttons.css under the .gear-cog-btn class — aria-pressed="true" tints the button blue so the edit-mode state is glanceable. !important is needed because the buttons carry Tailwind dark:bg-slate-* utility classes that win the cascade under the Tailwind v4 Play CDN (issue #99) regardless of selector specificity or source order; flagging the pressed-state palette as !important is the lowest-friction way to make it deterministic without stripping the utility classes off every cog. Verified via Chrome MCP direct DOM inspection: - Home cog rendered at top:10, right:1436 (24px from the 1460px viewport — upper-right corner as requested) - Click flips aria-pressed false ↔ true - Pressed state's background is rgba(30,58,138,0.4), color is rgb(147,197,253), border is rgba(96,165,250,0.5) — the dark-mode blue palette, overriding the slate dark:* utilities as intended Home gear: moved cog AFTER the "+ Add tile" button so it lands at the actual far right of the header row. JS updates set aria-pressed on click in both home.js and metrics-layout.js (kept the legacy label-toggle code path in home.js in case a future variant re-introduces text labels). Future gears can adopt the cog affordance by: 1. Adding a `<button class="gear-cog-btn ..." aria-pressed="false">` with the canonical SVG. 2. Toggling aria-pressed from their JS edit-mode handler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Enhance accessibility and user experience across modals and dialogs - Added ARIA roles and labels to modals in haproxy_config templates for improved screen reader support. - Included aria-label attributes on buttons to provide context for actions (e.g., close, cancel). - Implemented live value displays next to chart titles in metrics page for better visibility of current data. - Updated keydown event handlers to prevent default actions on Escape key to avoid unintended navigation or dialog closures. - Improved handling of focusable elements and overlays to ensure a smoother user experience when interacting with modals. * feat: implement InfoTooltip component and integrate into metrics layout * fix(#103): address Copilot review findings on PR #104 (round 2) Addresses the unresolved threads on PR #104 raised across the recent review passes. Each change is one-to-one with a finding: - metrics.templ: in-templ fullscreen Esc handler now preventDefaults so it doesn't fall through to the global Esc → history.back(). - metrics-layout.js patchLayout(): check `res.ok` and console.warn on non-2xx so 401/403/413/500 saves no longer fail silently. - metrics-layout.js applyCapabilityHiding(): switch the capability marker from `.hidden` to a dedicated `data-cap-hidden` attribute so fullscreen-driven `.hidden`s on neighbouring cards can't get mis-interpreted as capability hides. applyCapabilities() in the templ sets the new attribute; per-source templ cards carry the initial `data-cap-hidden="true"` so the marker is correct before the first applyCapabilities() pass runs. - metrics-layout.js: snapshot the (x,y,w,h) of each tile we remove from the engine into `capHiddenSnapshots` and merge those entries into `captureLayout()`, so a tile's last-known position survives a capability flip → re-flip cycle. - metrics-layout.js loadSavedLayout(): pass `addRemove:false` to `gs.load()` — without it GridStack yanks any DOM tile whose gs-id isn't in the loaded blob, which would permanently strip the capability-hidden DOM nodes we still need to re-attach later. - metrics-layout.js: fix updateContainerHeight() comment to describe what the code actually does (no margin term). - metrics.templ: rewrite the "waits on DOMContentLoaded" script-tag comment to match the actual `defer` execution semantics. - api_metrics_layout.go: normalise omitted `w`/`h` (GridStack drops them when they equal the default of 1) before the positive check, so resizing a tile to 1×N or N×1 no longer fails validation. Plus add column-count validation (x < 12, x+w ≤ 12) so a misbehaving client can't persist tiles that will silently clamp on next render. Tests cover both new behaviours plus a regression for the omitted-w/h case. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0384a1e commit 48f6c7a

24 files changed

Lines changed: 1883 additions & 134 deletions

File tree

CLAUDE.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,41 @@ await showAlertDialog({
175175

176176
Existing reference usages: [user-pages/admin-user-detail.js](static/js/user-pages/admin-user-detail.js), [user-pages/profile-management.js](static/js/user-pages/profile-management.js), [haproxy_config/editor.js](static/js/haproxy_config/editor.js).
177177

178+
**When editing any JS or templ file, grep for stray native popups before committing:**
179+
180+
```bash
181+
rg -n '\b(window\.)?(alert|confirm|prompt)\(' gearbox/static/js gearbox/internal --type-add 'templ:*.templ' --type js --type templ
182+
```
183+
184+
Treat any hit (outside `static/js/vendor/`) as a bug — replace with the dialog API above. The native primitives strip styling, dark mode, focus traps, and the Esc-handling chain we depend on.
185+
186+
### Info tooltips — always use the shared `InfoTooltip` widget
187+
188+
Any "what is this?" hover affordance — KPI labels, table column headers, settings rows with a non-obvious effect, etc. — must use the shared widget. The canonical look (filled info-circle "i" icon, dark hover bubble, no cursor change, no transition delay) is defined in two places that are kept in lock-step:
189+
190+
- **Server-rendered (templ):** [`@components.InfoTooltip(text)`](gearbox/internal/framework/templates/components/info_tooltip.templ).
191+
- **Client-built (JS):** `window.createInfoTooltip(text)` / `window.appendInfoTooltipTo(parentEl, text)` from [info-tooltip.js](gearbox/static/js/common/info-tooltip.js), already wired into [base.templ](gearbox/internal/framework/templates/layouts/base.templ) so every page has them.
192+
193+
```templ
194+
import "github.com/sarg3nt/gearbox/internal/framework/templates/components"
195+
196+
<span class="font-medium">Error Rate</span>
197+
@components.InfoTooltip("Percentage of requests with 4xx or 5xx status in the selected window.")
198+
```
199+
200+
```javascript
201+
// In a JS-built widget (e.g. a chart card or KPI band):
202+
const labelEl = card.querySelector('.kpi-label');
203+
window.appendInfoTooltipTo(labelEl, c.description);
204+
```
205+
206+
**Rules of thumb:**
207+
208+
- **Do not** roll your own `?`-button, `title=`-only tooltips, or `cursor: help` affordances — they look inconsistent with the HAProxy overview pattern, which is the visual reference for every other gear.
209+
- **Do not** put the rich tooltip text in a `title=` attribute on a card-body — users don't know to hover invisible regions. The widget is the visible cue.
210+
- If the text needs structure (bold lead-in + body paragraph, multiple paragraphs), drop the same wrapper markup inline rather than extending the component — see the "VPN Gateway Architecture" usage in [overview.templ](gearbox/internal/framework/templates/pages/overview.templ) for the pattern.
211+
- The widget is pure CSS — no JS event wiring needed and no cursor change. If you find yourself adding `onclick` or `cursor: help`, you've drifted from the standard.
212+
178213
### Toggle switches — never use a bare `<input type="checkbox">` in templates
179214

180215
For every boolean input in a `.templ` file — feature opt-ins, settings, "enable this gear", "show all", per-row enable/disable — use the shared slider component in [internal/framework/ui/toggle.templ](gearbox/internal/framework/ui/toggle.templ):

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)