diff --git a/changelog.md b/changelog.md index 8fbece84..11b21608 100644 --- a/changelog.md +++ b/changelog.md @@ -3,6 +3,7 @@ ## [1.77.0] - 2026-08-15 - ADDED: **A Customize control on the dashboard: panels are reordered by dragging, widened or narrowed by dragging the right edge across a twelve column grid, and hidden with the eye, remembered per browser** - the order and the widths were fixed in the code, so a panel worth keeping an eye on, such as the MTProto proxy and the devices using it, sat below a domain list hundreds of rows long, there was no way to move it up, give it more room or drop a panel that never gets read, and a short panel beside a tall one left everything after it waiting below the taller one. +- CHANGED: **The dashboard layout is kept in b4's own configuration rather than in the browser** - the order, the hidden panels and the widths were held in browser storage alone, so a layout arranged on one machine was absent from every other one, and a new browser, a private window or a cleared site cache put the dashboard back to its defaults. The browser copy is still written and is read when b4 cannot be reached, so the page keeps its arrangement while the service is restarting. - CHANGED: **The runtime, live signal and blackhole panels lay themselves out by their own width rather than the width of the window** - each read the browser's breakpoints, so a panel given less than the full width of the page kept a layout sized for the whole screen and had its contents cut off at the panel edge. - FIXED: **The packets b4 makes itself left a router by whichever uplink the main routing table picked, not by the interface the set they belong to is routed through** - fakes, split segments and desync packets are sent by b4 rather than forwarded, so they carried only b4's own mark and the set's routing never applied to them. On a router balancing two WANs the connection opened over one and continued over the other, from a different public address, and the destination reset it; with a set routed through a tunnel those packets, and the server name inside them, went out over the plain uplink instead. - CHANGED: **A set's hand-picked firewall mark is refused, and one is assigned instead, when it carries every bit of the mark b4 puts on its own packets** - the two became indistinguishable, and the set's traffic was read as traffic b4 had injected. diff --git a/changelog_ru.md b/changelog_ru.md index 1bd3c8bb..0003338c 100644 --- a/changelog_ru.md +++ b/changelog_ru.md @@ -3,6 +3,7 @@ ## [1.77.0] - 2026-08-15 - ДОБАВЛЕНО: **Кнопка «Настроить» на главной странице: панели переставляются перетаскиванием, расширяются и сужаются потягиванием за правый край по сетке из двенадцати колонок, ненужные скрываются глазом, раскладка запоминается в браузере** - порядок и ширина были заданы в коде, поэтому панель, за которой хочется следить, например MTProto-прокси и устройства, которые им пользуются, стояла под списком доменов в сотни строк, не было способа поднять её выше, дать ей больше места или убрать панель, которую никто не читает, а низкая панель рядом с высокой заставляла всё, что идёт следом, ждать ниже высокой. +- ИЗМЕНЕНО: **Раскладка главной страницы хранится в конфигурации b4, а не в браузере** - порядок, скрытые панели и ширина лежали только в хранилище браузера, поэтому раскладка, собранная на одной машине, отсутствовала на всех остальных, а новый браузер, приватное окно или очистка данных сайта возвращали страницу к настройкам по умолчанию. Копия в браузере по-прежнему пишется и читается, когда b4 недоступен, так что страница сохраняет вид на время перезапуска сервиса. - ИЗМЕНЕНО: **Панели среды выполнения, живого сигнала и блэкхола строят раскладку по собственной ширине, а не по ширине окна** - каждая читала брейкпоинты браузера, поэтому панель, занимающая меньше всей ширины страницы, сохраняла раскладку под целый экран, и её содержимое обрезалось по краю панели. - ИСПРАВЛЕНО: **Пакеты, которые b4 создаёт сам, уходили с роутера через тот канал, который выбрала основная таблица маршрутизации, а не через интерфейс, назначенный сету** - подделки, разрезанные сегменты и пакеты рассинхронизации b4 отправляет сам, а не пересылает, поэтому они несли только собственную метку b4, и маршрутизация сета к ним не применялась. На роутере с балансировкой двух WAN соединение открывалось через один канал, а продолжалось через другой, с другого публичного адреса, и сервер сбрасывал его; у сета с маршрутизацией через туннель эти пакеты, вместе с именем сервера внутри них, уходили через обычный канал. - ИЗМЕНЕНО: **Метка файрвола, выбранная для сета вручную, отклоняется, и вместо неё назначается своя, если она несёт все биты метки, которой b4 помечает собственные пакеты** - такие метки становились неотличимы, и трафик сета читался как пакеты, вброшенные самим b4. diff --git a/docs/static/swagger-versions/v1.77.0.json b/docs/static/swagger-versions/v1.77.0.json index c359db59..9564801f 100644 --- a/docs/static/swagger-versions/v1.77.0.json +++ b/docs/static/swagger-versions/v1.77.0.json @@ -3231,6 +3231,31 @@ } } }, + "/ui/dashboard": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Panel order, hidden panels and column widths for the dashboard.", + "produces": [ + "application/json" + ], + "tags": [ + "UI" + ], + "summary": "Get the dashboard layout", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/config.DashboardLayout" + } + } + } + } + }, "/version": { "get": { "produces": [ @@ -3675,6 +3700,9 @@ "system": { "$ref": "#/definitions/config.SystemConfig" }, + "ui": { + "$ref": "#/definitions/config.UIConfig" + }, "version": { "type": "integer" } @@ -3729,6 +3757,29 @@ } } }, + "config.DashboardLayout": { + "type": "object", + "properties": { + "hidden": { + "type": "array", + "items": { + "type": "string" + } + }, + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "spans": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + }, "config.DesyncConfig": { "type": "object", "properties": { @@ -4621,6 +4672,14 @@ } } }, + "config.UIConfig": { + "type": "object", + "properties": { + "dashboard": { + "$ref": "#/definitions/config.DashboardLayout" + } + } + }, "config.UpstreamProxyConfig": { "type": "object", "properties": { @@ -5106,6 +5165,9 @@ "system": { "$ref": "#/definitions/config.SystemConfig" }, + "ui": { + "$ref": "#/definitions/config.UIConfig" + }, "version": { "type": "integer" }, diff --git a/docs/static/swagger.json b/docs/static/swagger.json index c359db59..9564801f 100644 --- a/docs/static/swagger.json +++ b/docs/static/swagger.json @@ -3231,6 +3231,31 @@ } } }, + "/ui/dashboard": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Panel order, hidden panels and column widths for the dashboard.", + "produces": [ + "application/json" + ], + "tags": [ + "UI" + ], + "summary": "Get the dashboard layout", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/config.DashboardLayout" + } + } + } + } + }, "/version": { "get": { "produces": [ @@ -3675,6 +3700,9 @@ "system": { "$ref": "#/definitions/config.SystemConfig" }, + "ui": { + "$ref": "#/definitions/config.UIConfig" + }, "version": { "type": "integer" } @@ -3729,6 +3757,29 @@ } } }, + "config.DashboardLayout": { + "type": "object", + "properties": { + "hidden": { + "type": "array", + "items": { + "type": "string" + } + }, + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "spans": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + }, "config.DesyncConfig": { "type": "object", "properties": { @@ -4621,6 +4672,14 @@ } } }, + "config.UIConfig": { + "type": "object", + "properties": { + "dashboard": { + "$ref": "#/definitions/config.DashboardLayout" + } + } + }, "config.UpstreamProxyConfig": { "type": "object", "properties": { @@ -5106,6 +5165,9 @@ "system": { "$ref": "#/definitions/config.SystemConfig" }, + "ui": { + "$ref": "#/definitions/config.UIConfig" + }, "version": { "type": "integer" }, diff --git a/src/config/config.go b/src/config/config.go index 921da2cb..8965951c 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -12,6 +12,7 @@ type Config struct { Queue QueueConfig `json:"queue"` System SystemConfig `json:"system"` Sets []*SetConfig `json:"sets"` + UI UIConfig `json:"ui"` tcpPortMap map[uint16]bool // pre-computed TCP port set for fast lookup in packet handler } diff --git a/src/config/ui.go b/src/config/ui.go new file mode 100644 index 00000000..a20f49e2 --- /dev/null +++ b/src/config/ui.go @@ -0,0 +1,76 @@ +package config + +const ( + maxDashboardPanels = 64 + maxDashboardIDLen = 64 + minDashboardSpan = 1 + maxDashboardSpan = 12 + dashboardSpanLimit = 64 +) + +type UIConfig struct { + Dashboard DashboardLayout `json:"dashboard"` +} + +type DashboardLayout struct { + Order []string `json:"order,omitempty"` + Hidden []string `json:"hidden,omitempty"` + Spans map[string]int `json:"spans,omitempty"` +} + +func (l DashboardLayout) Sanitized() DashboardLayout { + out := DashboardLayout{ + Order: sanitizePanelIDs(l.Order), + Hidden: sanitizePanelIDs(l.Hidden), + } + + if len(l.Spans) > 0 { + out.Spans = make(map[string]int, len(l.Spans)) + for id, span := range l.Spans { + if len(out.Spans) >= dashboardSpanLimit { + break + } + if !validPanelID(id) { + continue + } + if span < minDashboardSpan { + span = minDashboardSpan + } + if span > maxDashboardSpan { + span = maxDashboardSpan + } + out.Spans[id] = span + } + if len(out.Spans) == 0 { + out.Spans = nil + } + } + + return out +} + +func sanitizePanelIDs(ids []string) []string { + if len(ids) == 0 { + return nil + } + seen := make(map[string]bool, len(ids)) + out := make([]string, 0, len(ids)) + for _, id := range ids { + if len(out) >= maxDashboardPanels { + break + } + if !validPanelID(id) || seen[id] { + continue + } + seen[id] = true + out = append(out, id) + } + if len(out) == 0 { + return nil + } + return out +} + +func validPanelID(id string) bool { + return id != "" && len(id) <= maxDashboardIDLen +} diff --git a/src/config/ui_sparse_test.go b/src/config/ui_sparse_test.go new file mode 100644 index 00000000..ef8cf1c3 --- /dev/null +++ b/src/config/ui_sparse_test.go @@ -0,0 +1,57 @@ +package config + +import ( + "strings" + "testing" +) + +func TestDashboardLayoutOmittedAtDefaults(t *testing.T) { + cfg := NewConfig() + data, err := MarshalSparse(&cfg) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "\"ui\"") { + t.Fatalf("empty ui block should be omitted, got: %s", data) + } +} + +func TestDashboardLayoutPersisted(t *testing.T) { + cfg := NewConfig() + cfg.UI.Dashboard = DashboardLayout{ + Order: []string{"mtproto", "runtime"}, + Hidden: []string{"blackhole"}, + Spans: map[string]int{"runtime": 4}, + } + data, err := MarshalSparse(&cfg) + if err != nil { + t.Fatal(err) + } + s := string(data) + for _, want := range []string{"\"ui\"", "mtproto", "blackhole", "\"runtime\": 4"} { + if !strings.Contains(s, want) { + t.Fatalf("expected %q in %s", want, s) + } + } +} + +func TestDashboardLayoutSanitized(t *testing.T) { + in := DashboardLayout{ + Order: []string{"a", "a", "", strings.Repeat("x", 100), "b"}, + Hidden: nil, + Spans: map[string]int{"a": 99, "b": -3, "": 5}, + } + out := in.Sanitized() + if len(out.Order) != 2 || out.Order[0] != "a" || out.Order[1] != "b" { + t.Fatalf("order not sanitized: %#v", out.Order) + } + if out.Hidden != nil { + t.Fatalf("empty hidden should stay nil: %#v", out.Hidden) + } + if out.Spans["a"] != 12 || out.Spans["b"] != 1 { + t.Fatalf("spans not clamped: %#v", out.Spans) + } + if _, ok := out.Spans[""]; ok { + t.Fatal("empty id should be dropped") + } +} diff --git a/src/http/handler/common.go b/src/http/handler/common.go index c57fb15a..6be1855b 100644 --- a/src/http/handler/common.go +++ b/src/http/handler/common.go @@ -169,6 +169,7 @@ func (api *API) RegisterEndpoints(mux *http.ServeMux, cfgPtr *atomic.Pointer[con api.geodataManager.UpdatePaths(cfg.System.Geo.GeoSitePath, cfg.System.Geo.GeoIpPath) api.RegisterConfigApi() + api.RegisterUIApi() api.RegisterMetricsApi() api.RegisterGeositeApi() api.RegisterGeoipApi() diff --git a/src/http/handler/ui.go b/src/http/handler/ui.go new file mode 100644 index 00000000..52fa2618 --- /dev/null +++ b/src/http/handler/ui.go @@ -0,0 +1,52 @@ +// src/http/handler/ui.go +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/daniellavrushin/b4/config" + "github.com/daniellavrushin/b4/log" +) + +func (api *API) RegisterUIApi() { + api.mux.HandleFunc("/api/ui/dashboard", api.handleDashboardLayout) +} + +// @Summary Get the dashboard layout +// @Description Panel order, hidden panels and column widths for the dashboard. +// @Tags UI +// @Produce json +// @Success 200 {object} config.DashboardLayout +// @Security BearerAuth +// @Router /ui/dashboard [get] +func (a *API) handleDashboardLayout(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + setJsonHeader(w) + _ = json.NewEncoder(w).Encode(a.getCfg().UI.Dashboard) + + case http.MethodPut: + var layout config.DashboardLayout + if err := json.NewDecoder(r.Body).Decode(&layout); err != nil { + writeAPIError(w, ErrValidation("Invalid dashboard layout")) + return + } + + newCfg := a.getCfg().Clone() + newCfg.UI.Dashboard = layout.Sanitized() + + if err := newCfg.SaveToFile(newCfg.ConfigPath); err != nil { + log.Errorf("Failed to save dashboard layout: %v", err) + writeAPIError(w, ErrInternal("Failed to save dashboard layout")) + return + } + a.cfgPtr.Store(newCfg) + + setJsonHeader(w) + _ = json.NewEncoder(w).Encode(newCfg.UI.Dashboard) + + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} diff --git a/src/http/handler/ui_test.go b/src/http/handler/ui_test.go new file mode 100644 index 00000000..bb73085b --- /dev/null +++ b/src/http/handler/ui_test.go @@ -0,0 +1,97 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/daniellavrushin/b4/config" +) + +func newUITestAPI(t *testing.T) (*API, *http.ServeMux, string) { + t.Helper() + cfgPath := filepath.Join(t.TempDir(), "b4.json") + cfg := config.NewConfig() + cfg.ConfigPath = cfgPath + api := &API{cfgPtr: testCfgPtr(&cfg)} + mux := http.NewServeMux() + api.mux = mux + api.RegisterUIApi() + return api, mux, cfgPath +} + +func TestDashboardLayoutRoundTrip(t *testing.T) { + api, mux, cfgPath := newUITestAPI(t) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/ui/dashboard", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET: expected 200, got %d", rec.Code) + } + var empty config.DashboardLayout + if err := json.NewDecoder(rec.Body).Decode(&empty); err != nil { + t.Fatalf("GET: decode: %v", err) + } + if len(empty.Order) != 0 || len(empty.Hidden) != 0 || len(empty.Spans) != 0 { + t.Fatalf("GET: expected an empty layout, got %#v", empty) + } + + body := `{"order":["mtproto","runtime"],"hidden":["blackhole"],"spans":{"runtime":99}}` + rec = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/ui/dashboard", strings.NewReader(body)) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PUT: expected 200, got %d (%s)", rec.Code, rec.Body.String()) + } + + stored := api.getCfg().UI.Dashboard + if len(stored.Order) != 2 || stored.Order[0] != "mtproto" { + t.Fatalf("PUT: order not stored: %#v", stored.Order) + } + if stored.Spans["runtime"] != 12 { + t.Fatalf("PUT: span not clamped to 12: %#v", stored.Spans) + } + + saved, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("config file not written: %v", err) + } + if !strings.Contains(string(saved), "mtproto") { + t.Fatalf("layout missing from saved config: %s", saved) + } + + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/ui/dashboard", nil)) + var reloaded config.DashboardLayout + if err := json.NewDecoder(rec.Body).Decode(&reloaded); err != nil { + t.Fatalf("GET after PUT: decode: %v", err) + } + if len(reloaded.Hidden) != 1 || reloaded.Hidden[0] != "blackhole" { + t.Fatalf("GET after PUT: hidden not returned: %#v", reloaded.Hidden) + } +} + +func TestDashboardLayoutRejectsBadBody(t *testing.T) { + _, mux, _ := newUITestAPI(t) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/ui/dashboard", strings.NewReader("not json")) + mux.ServeHTTP(rec, req) + if rec.Code == http.StatusOK { + t.Fatalf("expected a non-200 for a malformed body, got %d", rec.Code) + } +} + +func TestDashboardLayoutMethodNotAllowed(t *testing.T) { + _, mux, _ := newUITestAPI(t) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/api/ui/dashboard", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", rec.Code) + } +} diff --git a/src/http/ui/src/components/dashboard/Page.tsx b/src/http/ui/src/components/dashboard/Page.tsx index a252224a..9943ddc9 100644 --- a/src/http/ui/src/components/dashboard/Page.tsx +++ b/src/http/ui/src/components/dashboard/Page.tsx @@ -18,7 +18,7 @@ import { SortableContext } from "@dnd-kit/sortable"; import { useTranslation } from "react-i18next"; import { HealthBanner } from "./HealthBanner"; import { CustomizeBar, HiddenPanelEntry } from "./CustomizeBar"; -import { PanelFrame, PanelGhost, ROW_UNIT } from "./PanelFrame"; +import { ColumnGuides, PanelFrame, PanelGhost, ROW_UNIT } from "./PanelFrame"; import { PANELS_BY_ID, PanelContext } from "./registry"; import { normalizeMetrics } from "./normalize"; import { useDashboardSets } from "@hooks/useDashboardSets"; @@ -40,6 +40,7 @@ export function DashboardPage() { const [editing, setEditing] = useState(false); const [activeId, setActiveId] = useState(null); const [overId, setOverId] = useState(null); + const [resizingPanel, setResizingPanel] = useState(false); const wsRef = useRef(null); const { sets, targetedDomains, refresh: refreshSets } = useDashboardSets(); const { order, hidden, spans, move, setSpan, setHidden, reset, customized } = @@ -186,6 +187,7 @@ export function DashboardPage() { > + {resizingPanel && } {visiblePanels.map((panel) => ( setSpan(panel.id, value)} + onResizeActive={setResizingPanel} onHide={() => setHidden(panel.id, true)} > {panel.render(panelContext)} diff --git a/src/http/ui/src/components/dashboard/PanelFrame.tsx b/src/http/ui/src/components/dashboard/PanelFrame.tsx index 962a5f4e..a16654b7 100644 --- a/src/http/ui/src/components/dashboard/PanelFrame.tsx +++ b/src/http/ui/src/components/dashboard/PanelFrame.tsx @@ -6,11 +6,16 @@ import { useTranslation } from "react-i18next"; import { colors, radiusPx } from "@design"; import { DragIcon, HideIcon } from "@b4.icons"; import { B4TooltipButton } from "@common/B4TooltipButton"; -import { GRID_COLUMNS } from "./registry"; +import { GRID_COLUMNS, MIN_SPAN } from "./registry"; const GRID_GAP = 12; export const ROW_UNIT = 4; +const clampSpan = (value: number): number => + Math.min(GRID_COLUMNS, Math.max(MIN_SPAN, Math.round(value))); + + + interface PanelFrameProps { id: string; title: string; @@ -18,6 +23,7 @@ interface PanelFrameProps { editing: boolean; dropTarget: boolean; onSpanChange: (span: number) => void; + onResizeActive: (active: boolean) => void; onHide: () => void; children: ReactNode; } @@ -29,6 +35,7 @@ export const PanelFrame = ({ editing, dropTarget, onSpanChange, + onResizeActive, onHide, children, }: PanelFrameProps) => { @@ -36,7 +43,9 @@ export const PanelFrame = ({ const frameRef = useRef(null); const contentRef = useRef(null); const [resizing, setResizing] = useState(false); + const [previewSpan, setPreviewSpan] = useState(null); const [rowSpan, setRowSpan] = useState(1); + const shownSpan = previewSpan ?? span; useEffect(() => { const el = contentRef.current; @@ -74,9 +83,12 @@ export const PanelFrame = ({ /* pointer already released */ } setResizing(true); + onResizeActive(true); + let latest = startSpan; const onMove = (moveEvent: PointerEvent) => { - onSpanChange(startSpan + (moveEvent.clientX - startX) / step); + latest = clampSpan(startSpan + (moveEvent.clientX - startX) / step); + setPreviewSpan(latest); }; const onEnd = () => { try { @@ -88,6 +100,9 @@ export const PanelFrame = ({ grip.removeEventListener("pointerup", onEnd); grip.removeEventListener("pointercancel", onEnd); setResizing(false); + onResizeActive(false); + setPreviewSpan(null); + if (latest !== startSpan) onSpanChange(latest); }; grip.addEventListener("pointermove", onMove); @@ -99,7 +114,7 @@ export const PanelFrame = ({ {t("dashboard.customize.columns", { - span, + span: shownSpan, total: GRID_COLUMNS, })} @@ -226,6 +241,32 @@ export const PanelFrame = ({ ); }; +export const ColumnGuides = () => ( + + {Array.from({ length: GRID_COLUMNS }, (_, i) => ( + + ))} + +); + export const PanelGhost = ({ title }: { title: string }) => ( ; +} interface StoredLayout { v: number; @@ -45,33 +54,63 @@ const emptyLayout = (): StoredLayout => ({ spans: {}, }); +const normalize = (raw: StoredDashboard): StoredLayout => { + const spans: Record = {}; + for (const [id, span] of Object.entries(raw.spans ?? {})) { + if (PANELS_BY_ID.has(id) && Number.isFinite(span)) { + spans[id] = clampSpan(span); + } + } + return { + v: LAYOUT_VERSION, + order: mergeOrder(Array.isArray(raw.order) ? raw.order : []), + hidden: (Array.isArray(raw.hidden) ? raw.hidden : []).filter((id) => + PANELS_BY_ID.has(id), + ), + spans, + }; +}; + const loadLayout = (): StoredLayout => { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return emptyLayout(); const parsed = JSON.parse(raw) as Partial; if (parsed?.v !== LAYOUT_VERSION) return emptyLayout(); - const spans: Record = {}; - for (const [id, span] of Object.entries(parsed.spans ?? {})) { - if (PANELS_BY_ID.has(id) && Number.isFinite(span)) { - spans[id] = clampSpan(span); - } - } - return { - v: LAYOUT_VERSION, - order: mergeOrder(Array.isArray(parsed.order) ? parsed.order : []), - hidden: (Array.isArray(parsed.hidden) ? parsed.hidden : []).filter((id) => - PANELS_BY_ID.has(id), - ), - spans, - }; + return normalize(parsed); } catch { return emptyLayout(); } }; +const isCustomized = (layout: StoredLayout): boolean => + layout.hidden.length > 0 || + Object.keys(layout.spans).length > 0 || + layout.order.join() !== defaultOrder().join(); + export function useDashboardLayout() { const [layout, setLayout] = useState(loadLayout); + const hydrated = useRef(false); + const dirty = useRef(false); + + useEffect(() => { + let cancelled = false; + apiGet(LAYOUT_ENDPOINT) + .then((remote) => { + if (cancelled || dirty.current) return; + const next = normalize(remote ?? {}); + if (isCustomized(next)) setLayout(next); + }) + .catch(() => { + /* keep the local layout while b4 is unreachable */ + }) + .finally(() => { + if (!cancelled) hydrated.current = true; + }); + return () => { + cancelled = true; + }; + }, []); useEffect(() => { try { @@ -79,47 +118,70 @@ export function useDashboardLayout() { } catch { /* storage unavailable */ } + + if (!hydrated.current || !dirty.current) return; + const timer = setTimeout(() => { + void apiPut(LAYOUT_ENDPOINT, { + order: layout.order, + hidden: layout.hidden, + spans: layout.spans, + }).catch(() => { + /* layout stays in localStorage until the next change */ + }); + }, SAVE_DEBOUNCE_MS); + return () => clearTimeout(timer); }, [layout]); + const mutate = useCallback( + (updater: (prev: StoredLayout) => StoredLayout) => { + dirty.current = true; + setLayout(updater); + }, + [], + ); + const hidden = useMemo(() => new Set(layout.hidden), [layout.hidden]); - const move = useCallback((activeId: string, overId: string) => { - setLayout((prev) => { - const from = prev.order.indexOf(activeId); - const to = prev.order.indexOf(overId); - if (from < 0 || to < 0 || from === to) return prev; - const order = [...prev.order]; - order.splice(to, 0, order.splice(from, 1)[0]); - return { ...prev, order }; - }); - }, []); - - const setSpan = useCallback((id: string, span: number) => { - setLayout((prev) => { - const next = clampSpan(span); - if (prev.spans[id] === next) return prev; - return { ...prev, spans: { ...prev.spans, [id]: next } }; - }); - }, []); - - const setHidden = useCallback((id: string, value: boolean) => { - setLayout((prev) => { - const next = prev.hidden.filter((entry) => entry !== id); - if (value) next.push(id); - return { ...prev, hidden: next }; - }); - }, []); - - const reset = useCallback(() => setLayout(emptyLayout()), []); - - const customized = useMemo( - () => - layout.hidden.length > 0 || - Object.keys(layout.spans).length > 0 || - layout.order.join() !== defaultOrder().join(), - [layout], + const move = useCallback( + (activeId: string, overId: string) => { + mutate((prev) => { + const from = prev.order.indexOf(activeId); + const to = prev.order.indexOf(overId); + if (from < 0 || to < 0 || from === to) return prev; + const order = [...prev.order]; + order.splice(to, 0, order.splice(from, 1)[0]); + return { ...prev, order }; + }); + }, + [mutate], ); + const setSpan = useCallback( + (id: string, span: number) => { + mutate((prev) => { + const next = clampSpan(span); + if (prev.spans[id] === next) return prev; + return { ...prev, spans: { ...prev.spans, [id]: next } }; + }); + }, + [mutate], + ); + + const setHidden = useCallback( + (id: string, value: boolean) => { + mutate((prev) => { + const next = prev.hidden.filter((entry) => entry !== id); + if (value) next.push(id); + return { ...prev, hidden: next }; + }); + }, + [mutate], + ); + + const reset = useCallback(() => mutate(emptyLayout), [mutate]); + + const customized = useMemo(() => isCustomized(layout), [layout]); + return { order: layout.order, hidden,