diff --git a/internal/api/dashboard.go b/internal/api/dashboard.go index 01bf8a7..8232410 100644 --- a/internal/api/dashboard.go +++ b/internal/api/dashboard.go @@ -228,7 +228,17 @@ func (s *Server) updateDashboard(w http.ResponseWriter, r *http.Request) { layout := currentLayout if req.Widgets != nil { - clean, err := parseDashboardLayout([]byte(fmt.Sprintf(`{"widgets":%s}`, req.Widgets))) + // Always stamps the server's current layoutVersion — updateDashboardRequest + // has no Version field, so a client-sent one was silently dropped by + // the JSON decode above, and reconstructing this object as just + // {"widgets": ...} left storedLayout.Version at its zero value, which + // `omitempty` then dropped from the saved JSON entirely. The next load + // read that back as version 0, which the frontend's migration check + // (correctly) treats as "unversioned" and re-runs the v1->v2 doubling + // of every widget's height/y — on every single edit, compounding + // forever. The server owns the schema version; it shouldn't depend on + // the client sending one back correctly anyway. + clean, err := parseDashboardLayout([]byte(fmt.Sprintf(`{"version":%d,"widgets":%s}`, layoutVersion, req.Widgets))) if err != nil { writeErrorMsg(w, http.StatusBadRequest, err.Error()) return diff --git a/internal/api/dashboard_test.go b/internal/api/dashboard_test.go new file mode 100644 index 0000000..4eb173d --- /dev/null +++ b/internal/api/dashboard_test.go @@ -0,0 +1,58 @@ +package api + +import ( + "encoding/json" + "net/http" + "testing" +) + +// TestUpdateDashboardKeepsLayoutVersion locks in a real bug: saving a +// dashboard's widgets used to rebuild the stored layout as just +// {"widgets": ...} with no version key, so it round-tripped as version 0 +// once saved. The frontend correctly treats 0 as "unversioned" and re-runs +// its v1->v2 migration (doubling every widget's height/y) on every single +// load after that — a widget's saved size would grow every time the +// dashboard was reopened. A save must always stamp the current +// layoutVersion, regardless of what (if anything) the client sent. +func TestUpdateDashboardKeepsLayoutVersion(t *testing.T) { + e := newTestEnv(t) + cookie := e.loginAs(t, "admin", "admin@example.com", "hunter22", true) + + created := decode[dashboardDTOTest](t, e.do(t, http.MethodPost, "/api/v1/dashboards/", map[string]string{"name": "Test"}, cookie)) + if created.Version != layoutVersion { + t.Fatalf("freshly created dashboard version = %d, want %d", created.Version, layoutVersion) + } + + widgets := `[{"id":"w1","type":"fleet-overview","x":0,"y":0,"w":12,"h":8}]` + updated := decode[dashboardDTOTest](t, e.do(t, http.MethodPut, "/api/v1/dashboards/"+created.ID, map[string]any{ + "widgets": json.RawMessage(widgets), + }, cookie)) + if updated.Version != layoutVersion { + t.Fatalf("version after update = %d, want %d (widgets would silently double in size on next load)", updated.Version, layoutVersion) + } + if len(updated.Widgets) != 1 || updated.Widgets[0].H != 8 { + t.Fatalf("widgets not saved as sent: %+v", updated.Widgets) + } + + // Re-fetching (simulating the next time the dashboard is opened) must + // still report the real version and the same, unchanged height. + refetched := decode[dashboardDTOTest](t, e.get(t, "/api/v1/dashboards/"+created.ID, cookie)) + if refetched.Version != layoutVersion { + t.Fatalf("version on reload = %d, want %d", refetched.Version, layoutVersion) + } + if len(refetched.Widgets) != 1 || refetched.Widgets[0].H != 8 { + t.Fatalf("widget height changed across reload: %+v", refetched.Widgets) + } +} + +// dashboardDTOTest mirrors dashboardDTO's actual wire shape (its MarshalJSON +// output), which the real type can't be decoded back into directly. +type dashboardDTOTest struct { + ID string `json:"id"` + Name string `json:"name"` + Version int `json:"version"` + Widgets []struct { + ID string `json:"id"` + H int `json:"h"` + } `json:"widgets"` +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 24a879c..0af8e59 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -477,7 +477,23 @@ export function DashboardPage() { layouts={{ // Desktop keeps the saved 12-column layout; smaller breakpoints get // a derived full-width stack so tablets/phones never overflow. - lg: widgets.map((w) => ({ i: w.id, x: w.x, y: w.y, w: w.w, h: w.h })), + // minW/minH keep a resize from being dragged down to an unusably + // tiny sliver (a real complaint on its own — nothing stops the + // handle from being dragged too far without a floor) and maxH + // caps a drag/collision cascade from running away to an + // absurd height. Bounds come off each widget's own catalog + // default (roughly its smallest still-readable size), not a + // single fixed number, since a full-width chart and a small KPI + // tile need very different floors. + lg: widgets.map((w) => { + const spec = WIDGET_CATALOG.find((c) => c.type === w.type) + return { + i: w.id, x: w.x, y: w.y, w: w.w, h: w.h, + minW: spec ? Math.min(spec.defaultSize.w, 3) : 2, + minH: spec ? Math.min(spec.defaultSize.h, 4) : 4, + maxH: 60, + } + }), sm: (() => { let y = 0 return widgets.map((w) => {