feat(#103): metrics page — per-user, per-box draggable layout (GridStack) - #104
Merged
Conversation
…ack) 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>
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a GridStack-driven, per-user/per-box persisted layout system for the Metrics page in the Gearbox dashboard, enabling operators to drag/resize chart tiles and save/reset their personal layout per server.
Changes:
- Introduces a new
metrics_layoutsDB table plus read/write/delete helpers for storing GridStacksave()JSON per(user_id, server_id). - Adds new JSON API endpoints under
/api/{boxID}/metrics/layout(GET/PATCH/DELETE) to load, persist, and reset layouts. - Updates the Metrics page template + frontend JS to render charts as GridStack widgets, toggle edit mode, and reflow tiles when capability-gated cards appear/disappear.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
gearbox/static/js/metrics-layout.js |
Initializes GridStack on the Metrics chart grid, handles edit mode, capability-driven widget removal/re-add, and debounced persistence/reset calls. |
gearbox/internal/framework/templates/pages/metrics.templ |
Converts the chart grid to GridStack markup, adds edit/reset controls, wires capability reflow event, and includes GridStack vendor assets + new JS. |
gearbox/internal/framework/handler/api_metrics_layout.go |
Implements GET/PATCH/DELETE handlers for per-user per-box metrics layout persistence. |
gearbox/internal/framework/database/metrics_layouts.go |
Adds schema init and DB methods for saving/loading/deleting the metrics layout JSON blob. |
gearbox/internal/framework/database/database.go |
Wires initMetricsLayoutsSchema() into DB initialization. |
gearbox/cmd/server/main.go |
Registers the new metrics layout API routes. |
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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.
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.
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 24 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
gearbox/static/js/common/shortcut-help.js:251
- The global Esc handler relies on other dialogs calling
preventDefault()(it bails out one.defaultPrevented). The built-in Confirm/Prompt/Alert dialogs’ Esc handler inbase.templcloses those overlays but does not calle.preventDefault(), so a single Esc can close the dialog and then fall through here tohistory.back()in the same keypress. Update the dialog Esc handler(s) to calle.preventDefault()when they close something (or add an equivalent guard here) so Esc never triggers back-navigation immediately after dismissing a dialog.
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
// A per-modal handler already consumed it (cmdk, help,
// icon-picker, etc. all call preventDefault).
if (e.defaultPrevented) return;
if (closeTopmostDialog()) { e.preventDefault(); return; }
if (exitGearEditMode()) { e.preventDefault(); return; }
const el = document.activeElement;
if (isBlurrableTarget(el)) {
el.blur();
e.preventDefault();
return;
}
// Same-origin back nav only — `document.referrer` is empty
// on direct loads, so checking history.length > 1 is the
// best proxy we have in-browser.
if (window.history.length > 1) {
e.preventDefault();
window.history.back();
}
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #103. Metrics page chart grid becomes drag + resize editable, with the layout persisted per (user, box). Reuses the Home gear's GridStack vendor bundle — no new dependency. Default layout matches the row order proposed on the issue:
(* per-source tiles only appear when the agent's capability table reports the gear Available)
Files
New
internal/framework/database/metrics_layouts.gometrics_layoutstable (PK(user_id, server_id)) +GetMetricsLayout/SaveMetricsLayout/DeleteMetricsLayout+ErrNoMetricsLayoutsentinel.internal/framework/handler/api_metrics_layout.go[{id,x,y,w,h}].static/js/metrics-layout.jsapplyCapabilities().Modified
internal/framework/database/database.goinitMetricsLayoutsSchema()intoNew()right after the Home schema.cmd/server/main.go/metrics/...endpoints.internal/framework/templates/pages/metrics.templ.grid-stack-itemwith defaultgs-*attrs. Cards reordered to the layout above. Edit toggle + Reset button added to the controls bar. GridStack vendor CSS + JS +metrics-layout.jsscript tags added.applyCapabilities()dispatchesmetrics:capabilities-appliedso the JS layer can reflow without coupling.How the per-user → per-box persistence works
GET /api/{boxID}/metrics/layoutreturns 204 → JS keeps the template's default positions.Layout stored as a JSON blob rather than per-tile rows because the access pattern is always "fetch / write the whole layout"; there's no querying inside it. The blob is GridStack's
save()output verbatim — the storage layer never inspects it, which keeps it agnostic to GridStack version changes.Capability-driven reflow
PR #102 added per-source chart cards that hide via a
.hiddenclass when the agent reports the gear as Unavailable. GridStack doesn't know about those classes — without coordination, an unavailable source would leave a permanent gap in the grid.The fix is event-driven:
applyCapabilities()in metrics.templ's inline script toggles.hiddenper card as today, then dispatches ametrics:capabilities-appliedCustomEvent onwindow.metrics-layout.jslistens for that event and per-card decides whether to callgs.removeWidget(item, false)(keep DOM, drop the grid slot — surrounding tiles compact up) orgs.makeWidget(item)(re-add to the grid).metrics-layout.jsis suppressed during this reflow so the capability-driven moves don't accidentally PATCH the user's saved layout.Decoupling via CustomEvent means neither file needs to know about the other's globals.
Test plan
go test ./...clean ongearbox.go vet ./...clean.gofmt -lclean on every file added or modified.templ generateclean — generatedmetrics_templ.goregenerates with the new structure (gitignored as expected).mjolnir) — HAProxy tiles disappear, host tiles compact up to fill the grid with no gaps.Backwards compatibility
Purely additive.
/metrics/layoutpaths — no changes to existing/metrics/...endpoints.Out of scope
🤖 Generated with Claude Code