Skip to content

Commit 342713f

Browse files
committed
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.
1 parent ca4a5bd commit 342713f

4 files changed

Lines changed: 144 additions & 25 deletions

File tree

gearbox/internal/framework/handler/api_metrics_layout.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ const maxTilesPerLayout = 64
5555
const (
5656
maxCoord = 1000
5757
maxDim = 100
58+
// gridColumns mirrors the GridStack init in static/js/metrics-layout.js
59+
// (column: 12). Persisted x+w must fit inside this column count or
60+
// the saved tile would silently collide / clamp on next render,
61+
// which surfaces as "my layout didn't stick" confusion. Keep this
62+
// value in sync with the JS side if the column count ever changes.
63+
gridColumns = 12
5864
)
5965

6066
// layoutTile is the shape we require each entry in the posted
@@ -90,7 +96,20 @@ func validateLayoutTiles(tiles []layoutTile) error {
9096
return fmt.Errorf("layout has %d tiles; maximum %d", len(tiles), maxTilesPerLayout)
9197
}
9298
seen := make(map[string]struct{}, len(tiles))
93-
for i, t := range tiles {
99+
for i := range tiles {
100+
t := &tiles[i]
101+
// Normalise omitted w/h. GridStack.save() drops fields that
102+
// equal their defaults — including w=1 and h=1 — so a 1×N or
103+
// N×1 tile arrives with W or H as the Go zero value. Treat
104+
// those as the GridStack default of 1 rather than rejecting
105+
// the whole payload (the user resizing a tile to 1 col wide
106+
// would otherwise silently fail to persist).
107+
if t.W == 0 {
108+
t.W = 1
109+
}
110+
if t.H == 0 {
111+
t.H = 1
112+
}
94113
if t.ID == "" {
95114
return fmt.Errorf("tile %d: id is empty", i)
96115
}
@@ -110,6 +129,17 @@ func validateLayoutTiles(tiles []layoutTile) error {
110129
if t.W > maxDim || t.H > maxDim {
111130
return fmt.Errorf("tile %q: w/h exceed %d (got w=%d, h=%d)", t.ID, maxDim, t.W, t.H)
112131
}
132+
// Column-count check: a saved x/w pair that overflows the
133+
// 12-column grid can never render as-saved. Without this
134+
// check the payload would persist successfully, then load,
135+
// then silently get clamped by GridStack — and the user
136+
// would see "my layout didn't stick" with no diagnostic.
137+
if t.X >= gridColumns {
138+
return fmt.Errorf("tile %q: x must be < %d columns (got x=%d)", t.ID, gridColumns, t.X)
139+
}
140+
if t.X+t.W > gridColumns {
141+
return fmt.Errorf("tile %q: x+w exceeds %d columns (got x=%d, w=%d)", t.ID, gridColumns, t.X, t.W)
142+
}
113143
}
114144
return nil
115145
}

gearbox/internal/framework/handler/api_metrics_layout_test.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,28 @@ func TestValidateLayoutTiles(t *testing.T) {
2121
t.Errorf("good tiles rejected: %v", err)
2222
}
2323

24+
// GridStack.save() omits w/h when they're at their default of 1,
25+
// so a 1×N or N×1 tile arrives with W or H decoded as the Go
26+
// zero value. validateLayoutTiles must normalise those to 1 and
27+
// accept the tile rather than reject with "w/h must be positive".
28+
omitted := []layoutTile{
29+
{ID: "card-cpu", X: 0, Y: 0}, // W and H both omitted (=> 0)
30+
{ID: "card-mem", X: 1, Y: 0, W: 0, H: 2},
31+
{ID: "card-net", X: 2, Y: 0, W: 2, H: 0},
32+
}
33+
if err := validateLayoutTiles(omitted); err != nil {
34+
t.Errorf("tiles with omitted w/h rejected: %v", err)
35+
}
36+
if omitted[0].W != 1 || omitted[0].H != 1 {
37+
t.Errorf("expected omitted w/h normalised to 1, got w=%d h=%d", omitted[0].W, omitted[0].H)
38+
}
39+
if omitted[1].W != 1 {
40+
t.Errorf("expected W=0 normalised to 1, got w=%d", omitted[1].W)
41+
}
42+
if omitted[2].H != 1 {
43+
t.Errorf("expected H=0 normalised to 1, got h=%d", omitted[2].H)
44+
}
45+
2446
cases := []struct {
2547
name string
2648
tiles []layoutTile
@@ -59,11 +81,6 @@ func TestValidateLayoutTiles(t *testing.T) {
5981
tiles: []layoutTile{{ID: "card-cpu", X: maxCoord + 1, Y: 0, W: 1, H: 1}},
6082
wantMatch: "exceed",
6183
},
62-
{
63-
name: "zero width",
64-
tiles: []layoutTile{{ID: "card-cpu", X: 0, Y: 0, W: 0, H: 1}},
65-
wantMatch: "w/h must be positive",
66-
},
6784
{
6885
name: "negative height",
6986
tiles: []layoutTile{{ID: "card-cpu", X: 0, Y: 0, W: 1, H: -1}},
@@ -74,6 +91,16 @@ func TestValidateLayoutTiles(t *testing.T) {
7491
tiles: []layoutTile{{ID: "card-cpu", X: 0, Y: 0, W: maxDim + 1, H: 1}},
7592
wantMatch: "w/h exceed",
7693
},
94+
{
95+
name: "x at column boundary",
96+
tiles: []layoutTile{{ID: "card-cpu", X: gridColumns, Y: 0, W: 1, H: 1}},
97+
wantMatch: "x must be <",
98+
},
99+
{
100+
name: "x+w overflows columns",
101+
tiles: []layoutTile{{ID: "card-cpu", X: 8, Y: 0, W: 6, H: 1}},
102+
wantMatch: "x+w exceeds",
103+
},
77104
{
78105
name: "too many tiles",
79106
tiles: buildOversizedTiles(),

gearbox/internal/framework/templates/pages/metrics.templ

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ templ Metrics(user *models.User, servers []models.BoxConfig) {
300300
When a source becomes Available later, makeWidget()
301301
puts the tile back at its default position. -->
302302
<div class="grid-stack-item" gs-id="card-nginx" gs-x="0" gs-y="16" gs-w="6" gs-h="4">
303-
<div id="card-nginx" data-source="nginx" data-source-card="true" class="grid-stack-item-content chart-card bg-gray-50 dark:bg-slate-700 rounded-lg p-4 relative hidden">
303+
<div id="card-nginx" data-source="nginx" data-source-card="true" data-cap-hidden="true" class="grid-stack-item-content chart-card bg-gray-50 dark:bg-slate-700 rounded-lg p-4 relative hidden">
304304
<div class="flex justify-between items-center mb-2">
305305
<h4 class="text-lg font-medium text-gray-700 dark:text-gray-300"><span class="source-tag">nginx:</span> Connections &amp; Requests</h4>
306306
<button onclick="toggleFullscreen('card-nginx')" class="fullscreen-btn p-1 hover:bg-gray-200 dark:hover:bg-slate-600 rounded transition-colors" title="Toggle fullscreen">
@@ -315,7 +315,7 @@ templ Metrics(user *models.User, servers []models.BoxConfig) {
315315
</div>
316316
</div>
317317
<div class="grid-stack-item" gs-id="card-apache" gs-x="6" gs-y="16" gs-w="6" gs-h="4">
318-
<div id="card-apache" data-source="apache" data-source-card="true" class="grid-stack-item-content chart-card bg-gray-50 dark:bg-slate-700 rounded-lg p-4 relative hidden">
318+
<div id="card-apache" data-source="apache" data-source-card="true" data-cap-hidden="true" class="grid-stack-item-content chart-card bg-gray-50 dark:bg-slate-700 rounded-lg p-4 relative hidden">
319319
<div class="flex justify-between items-center mb-2">
320320
<h4 class="text-lg font-medium text-gray-700 dark:text-gray-300"><span class="source-tag">Apache:</span> Workers &amp; Requests</h4>
321321
<button onclick="toggleFullscreen('card-apache')" class="fullscreen-btn p-1 hover:bg-gray-200 dark:hover:bg-slate-600 rounded transition-colors" title="Toggle fullscreen">
@@ -330,7 +330,7 @@ templ Metrics(user *models.User, servers []models.BoxConfig) {
330330
</div>
331331
</div>
332332
<div class="grid-stack-item" gs-id="card-caddy" gs-x="0" gs-y="20" gs-w="6" gs-h="4">
333-
<div id="card-caddy" data-source="caddy" data-source-card="true" class="grid-stack-item-content chart-card bg-gray-50 dark:bg-slate-700 rounded-lg p-4 relative hidden">
333+
<div id="card-caddy" data-source="caddy" data-source-card="true" data-cap-hidden="true" class="grid-stack-item-content chart-card bg-gray-50 dark:bg-slate-700 rounded-lg p-4 relative hidden">
334334
<div class="flex justify-between items-center mb-2">
335335
<h4 class="text-lg font-medium text-gray-700 dark:text-gray-300"><span class="source-tag">Caddy:</span> Requests &amp; Errors</h4>
336336
<button onclick="toggleFullscreen('card-caddy')" class="fullscreen-btn p-1 hover:bg-gray-200 dark:hover:bg-slate-600 rounded transition-colors" title="Toggle fullscreen">
@@ -345,7 +345,7 @@ templ Metrics(user *models.User, servers []models.BoxConfig) {
345345
</div>
346346
</div>
347347
<div class="grid-stack-item" gs-id="card-traefik" gs-x="6" gs-y="20" gs-w="6" gs-h="4">
348-
<div id="card-traefik" data-source="traefik" data-source-card="true" class="grid-stack-item-content chart-card bg-gray-50 dark:bg-slate-700 rounded-lg p-4 relative hidden">
348+
<div id="card-traefik" data-source="traefik" data-source-card="true" data-cap-hidden="true" class="grid-stack-item-content chart-card bg-gray-50 dark:bg-slate-700 rounded-lg p-4 relative hidden">
349349
<div class="flex justify-between items-center mb-2">
350350
<h4 class="text-lg font-medium text-gray-700 dark:text-gray-300"><span class="source-tag">Traefik:</span> Status Class</h4>
351351
<button onclick="toggleFullscreen('card-traefik')" class="fullscreen-btn p-1 hover:bg-gray-200 dark:hover:bg-slate-600 rounded transition-colors" title="Toggle fullscreen">
@@ -1086,9 +1086,13 @@ templ Metrics(user *models.User, servers []models.BoxConfig) {
10861086
return mapping[cardId];
10871087
}
10881088

1089-
// Handle Escape key to exit fullscreen
1089+
// Handle Escape key to exit fullscreen. preventDefault so the
1090+
// global Esc handler in shortcut-help.js doesn't also fire on
1091+
// the same press (it falls through to history.back() when
1092+
// nothing else consumes Esc).
10901093
document.addEventListener('keydown', function(e) {
10911094
if (e.key === 'Escape' && currentFullscreenCard) {
1095+
e.preventDefault();
10921096
toggleFullscreen(currentFullscreenCard);
10931097
}
10941098
});
@@ -1498,9 +1502,16 @@ templ Metrics(user *models.User, servers []models.BoxConfig) {
14981502
}
14991503

15001504
// Toggle every element tagged data-source="haproxy". Includes
1501-
// the chart cards plus the Error Insights panel.
1505+
// the chart cards plus the Error Insights panel. We set
1506+
// both `.hidden` (for the visual hide) and
1507+
// `data-cap-hidden` (the dedicated marker metrics-layout.js
1508+
// reads so it can tell capability-driven hides apart from
1509+
// fullscreen-driven `.hidden`s on neighbouring cards).
15021510
document.querySelectorAll('[data-source="haproxy"]').forEach(function(el) {
15031511
el.classList.toggle('hidden', !haproxyAvailable);
1512+
if (el.classList.contains('chart-card')) {
1513+
el.dataset.capHidden = haproxyAvailable ? 'false' : 'true';
1514+
}
15041515
});
15051516

15061517
// Gate the per-source chart cards on each source's probe
@@ -1516,6 +1527,7 @@ templ Metrics(user *models.User, servers []models.BoxConfig) {
15161527
window._availableSources[src] = !!available;
15171528
document.querySelectorAll('[data-source="' + src + '"][data-source-card]').forEach(function(el) {
15181529
el.classList.toggle('hidden', !available);
1530+
el.dataset.capHidden = available ? 'false' : 'true';
15191531
});
15201532
}
15211533

@@ -2833,8 +2845,11 @@ templ Metrics(user *models.User, servers []models.BoxConfig) {
28332845

28342846
<!-- GridStack vendor JS (issue #103). Same bundle the Home
28352847
gear uses. Loaded with defer so it doesn't block initial
2836-
paint; the inline metrics-layout.js script below waits
2837-
on DOMContentLoaded before initialising. -->
2848+
paint; defer also guarantees both scripts run after the
2849+
DOM is parsed, in source order — so metrics-layout.js
2850+
can rely on GridStack being defined and on its target
2851+
#charts-grid element existing the moment its top-level
2852+
IIFE executes (no extra DOMContentLoaded gate needed). -->
28382853
<script src="/static/js/vendor/gridstack/gridstack-all.js" defer></script>
28392854
<script src="/static/js/metrics-layout.js" defer></script>
28402855
}

gearbox/static/js/metrics-layout.js

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,32 @@
104104
// both files sees the same idiom.
105105
let suppressChange = true;
106106

107+
// capHiddenSnapshots remembers the (x, y, w, h) of each tile we
108+
// pulled out of the grid because its source went unavailable. When
109+
// captureLayout() serialises the grid, those tiles aren't in the
110+
// engine (so gs.save() omits them) — but we still want their last-
111+
// known positions to ride along in the persisted blob so that a
112+
// capability flip back to Available a week later restores the
113+
// user's chosen position instead of dropping the tile at its templ
114+
// default coords.
115+
const capHiddenSnapshots = {};
116+
107117
/** captureLayout returns the current grid state in the same shape
108118
* the PATCH endpoint expects — array of {id, x, y, w, h}. We use
109119
* gs.save(false) (no node data, just positions); the dashboard
110-
* doesn't need the full GridStack node objects. */
120+
* doesn't need the full GridStack node objects. Capability-hidden
121+
* tiles aren't in the engine so we merge in their snapshots
122+
* before returning, otherwise the saved blob loses them. */
111123
function captureLayout() {
112-
return gs.save(false);
124+
const out = gs.save(false) || [];
125+
const present = new Set(out.map(function (t) { return t.id; }));
126+
Object.keys(capHiddenSnapshots).forEach(function (id) {
127+
if (!present.has(id)) {
128+
const s = capHiddenSnapshots[id];
129+
out.push({ id: id, x: s.x, y: s.y, w: s.w, h: s.h });
130+
}
131+
});
132+
return out;
113133
}
114134

115135
/** updateContainerHeight pins the grid's min-height to the bottom
@@ -123,9 +143,10 @@
123143
*
124144
* Math: GridStack positions each tile at top = y*cellHeight + margin/2
125145
* with height = h*cellHeight - margin. Bottom edge of the last
126-
* row = (y+h)*cellHeight. Adding the margin once at the end
127-
* matches GridStack's own positioning so the spacing below the
128-
* last row equals the spacing between rows. */
146+
* row at index (y+h) sits at (y+h)*cellHeight. We use that bottom
147+
* edge as min-height directly — GridStack already paints the
148+
* marginBottom inside the cellHeight allowance, so adding it
149+
* again would over-pad the section below the grid. */
129150
function updateContainerHeight() {
130151
// CAREFUL: GridStack's cellHeight() — note the parens — is an
131152
// implicit setter when called as a getter. It re-computes
@@ -166,16 +187,25 @@
166187
const card = item.querySelector(`#${id}`);
167188
if (!card) return;
168189

169-
// The inner card carries `.hidden` when capability gating
170-
// says the source isn't Available. Pull the surrounding
171-
// grid-stack-item out of the engine to free its slot.
172-
const isCapHidden = card.classList.contains("hidden");
190+
// The capability marker is a dedicated `data-cap-hidden`
191+
// attribute set by applyCapabilities() in metrics.templ — we
192+
// intentionally do NOT key off `.hidden`, because chart-
193+
// fullscreen.js also adds `.hidden` to every non-focused card
194+
// while one is fullscreened. Reading `.hidden` here would
195+
// remove the entire grid mid-fullscreen and never re-add it.
196+
const isCapHidden = card.dataset.capHidden === "true";
173197
const isInGrid = !!item.gridstackNode;
174198

175199
if (isCapHidden && isInGrid) {
200+
// Snapshot the position before removing so captureLayout()
201+
// can persist it. Without this the user's chosen position
202+
// is lost the moment a source goes unavailable.
203+
const n = item.gridstackNode;
204+
capHiddenSnapshots[id] = { x: n.x || 0, y: n.y || 0, w: n.w || 1, h: n.h || 1 };
176205
gs.removeWidget(item, false); // keep DOM, drop slot
177206
} else if (!isCapHidden && !isInGrid) {
178207
gs.makeWidget(item);
208+
delete capHiddenSnapshots[id];
179209
}
180210
});
181211
updateContainerHeight();
@@ -197,7 +227,13 @@
197227
if (res.status === 204 || !res.ok) return; // use template defaults
198228
const layout = await res.json();
199229
if (!Array.isArray(layout) || layout.length === 0) return;
200-
gs.load(layout);
230+
// addRemove:false — without it, gs.load() would yank any
231+
// grid-stack-item DOM nodes whose gs-id isn't in the loaded
232+
// array (capability-hidden tiles that were captured into
233+
// capHiddenSnapshots but not currently in the engine). We
234+
// intentionally keep those DOM nodes around so makeWidget()
235+
// can re-attach them later when their source goes Available.
236+
gs.load(layout, false);
201237
} catch (err) {
202238
console.debug("metrics layout load failed; using defaults", err);
203239
}
@@ -221,11 +257,22 @@
221257
const serverID = getServerID();
222258
if (!serverID) return;
223259
try {
224-
await fetch(`/api/${serverID}/metrics/layout`, {
260+
const res = await fetch(`/api/${serverID}/metrics/layout`, {
225261
method: "PATCH",
226262
headers: { "Content-Type": "application/json" },
227263
body: JSON.stringify(captureLayout()),
228264
});
265+
// fetch() doesn't throw on HTTP 4xx/5xx — surface non-2xx so a
266+
// 401/403/413/500 doesn't silently swallow the user's layout
267+
// edit. console.warn keeps it visible in devtools without
268+
// popping a dialog mid-edit.
269+
if (!res.ok) {
270+
console.warn(
271+
"metrics layout save returned non-2xx",
272+
res.status,
273+
res.statusText,
274+
);
275+
}
229276
} catch (err) {
230277
console.warn("metrics layout save failed", err);
231278
}

0 commit comments

Comments
 (0)