Fix dashboard widgets doubling in size on every save

Saving a dashboard's widget layout rebuilt the stored JSON without a
version field, so it was written to the database as an unversioned (v1)
layout every time, no matter what version the client actually sent. The
next time the dashboard loaded, the client's one-time v1-to-v2 migration
(doubling every widget's height and y position for the new row unit) ran
again, because the saved layout looked unversioned. Since that migrated
result got saved right back the same way, every save-reload cycle
doubled each widget's height again - this is what showed up as widgets
"randomly" growing or ending up a different size than what was saved.

The server now always stamps its own current layout version when saving,
instead of depending on the client to round-trip one correctly.

Also added minimum/maximum size limits to each widget type so a resize
can't be dragged down to an unreadable sliver or blown out to an
extreme height by accident, and a regression test for the versioning
bug itself.
This commit is contained in:
Anand
2026-09-14 13:55:33 +05:30
parent dbe6f0df1e
commit 444f862be9
3 changed files with 86 additions and 2 deletions
+11 -1
View File
@@ -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
+58
View File
@@ -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"`
}
+17 -1
View File
@@ -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) => {