feat(ui): implement customizable dashboard layout with persistent configuration

This commit is contained in:
Daniel Lavrushin 2026-08-16 02:31:56 +02:00 committed by Daniel Lavrushin
parent 06453f0bbf
commit 6f2c19f234
13 changed files with 572 additions and 55 deletions

View file

@ -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.

View file

@ -3,6 +3,7 @@
## [1.77.0] - 2026-08-15
- ДОБАВЛЕНО: **Кнопка «Настроить» на главной странице: панели переставляются перетаскиванием, расширяются и сужаются потягиванием за правый край по сетке из двенадцати колонок, ненужные скрываются глазом, раскладка запоминается в браузере** - порядок и ширина были заданы в коде, поэтому панель, за которой хочется следить, например MTProto-прокси и устройства, которые им пользуются, стояла под списком доменов в сотни строк, не было способа поднять её выше, дать ей больше места или убрать панель, которую никто не читает, а низкая панель рядом с высокой заставляла всё, что идёт следом, ждать ниже высокой.
- ИЗМЕНЕНО: **Раскладка главной страницы хранится в конфигурации b4, а не в браузере** - порядок, скрытые панели и ширина лежали только в хранилище браузера, поэтому раскладка, собранная на одной машине, отсутствовала на всех остальных, а новый браузер, приватное окно или очистка данных сайта возвращали страницу к настройкам по умолчанию. Копия в браузере по-прежнему пишется и читается, когда b4 недоступен, так что страница сохраняет вид на время перезапуска сервиса.
- ИЗМЕНЕНО: **Панели среды выполнения, живого сигнала и блэкхола строят раскладку по собственной ширине, а не по ширине окна** - каждая читала брейкпоинты браузера, поэтому панель, занимающая меньше всей ширины страницы, сохраняла раскладку под целый экран, и её содержимое обрезалось по краю панели.
- ИСПРАВЛЕНО: **Пакеты, которые b4 создаёт сам, уходили с роутера через тот канал, который выбрала основная таблица маршрутизации, а не через интерфейс, назначенный сету** - подделки, разрезанные сегменты и пакеты рассинхронизации b4 отправляет сам, а не пересылает, поэтому они несли только собственную метку b4, и маршрутизация сета к ним не применялась. На роутере с балансировкой двух WAN соединение открывалось через один канал, а продолжалось через другой, с другого публичного адреса, и сервер сбрасывал его; у сета с маршрутизацией через туннель эти пакеты, вместе с именем сервера внутри них, уходили через обычный канал.
- ИЗМЕНЕНО: **Метка файрвола, выбранная для сета вручную, отклоняется, и вместо неё назначается своя, если она несёт все биты метки, которой b4 помечает собственные пакеты** - такие метки становились неотличимы, и трафик сета читался как пакеты, вброшенные самим b4.

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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
}

76
src/config/ui.go Normal file
View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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()

52
src/http/handler/ui.go Normal file
View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
const [resizingPanel, setResizingPanel] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const { sets, targetedDomains, refresh: refreshSets } = useDashboardSets();
const { order, hidden, spans, move, setSpan, setHidden, reset, customized } =
@ -186,6 +187,7 @@ export function DashboardPage() {
>
<Box
sx={{
position: "relative",
display: "grid",
gridTemplateColumns: "repeat(12, minmax(0, 1fr))",
gridAutoRows: `${ROW_UNIT}px`,
@ -195,6 +197,7 @@ export function DashboardPage() {
rowGap: 0,
}}
>
{resizingPanel && <ColumnGuides />}
{visiblePanels.map((panel) => (
<PanelFrame
key={panel.id}
@ -204,6 +207,7 @@ export function DashboardPage() {
editing={editing}
dropTarget={overId === panel.id && activeId !== panel.id}
onSpanChange={(value) => setSpan(panel.id, value)}
onResizeActive={setResizingPanel}
onHide={() => setHidden(panel.id, true)}
>
{panel.render(panelContext)}

View file

@ -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<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [resizing, setResizing] = useState(false);
const [previewSpan, setPreviewSpan] = useState<number | null>(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 = ({
<Box
ref={setNodeRef}
sx={{
gridColumn: { xs: "span 12", xl: `span ${span}` },
gridColumn: { xs: "span 12", xl: `span ${shownSpan}` },
gridRow: `span ${rowSpan}`,
minWidth: 0,
containerType: "inline-size",
@ -177,7 +192,7 @@ export const PanelFrame = ({
}}
>
{t("dashboard.customize.columns", {
span,
span: shownSpan,
total: GRID_COLUMNS,
})}
</Typography>
@ -226,6 +241,32 @@ export const PanelFrame = ({
);
};
export const ColumnGuides = () => (
<Box
aria-hidden
sx={{
display: { xs: "none", xl: "grid" },
position: "absolute",
inset: 0,
gridTemplateColumns: `repeat(${GRID_COLUMNS}, minmax(0, 1fr))`,
columnGap: `${GRID_GAP}px`,
pointerEvents: "none",
zIndex: 2,
}}
>
{Array.from({ length: GRID_COLUMNS }, (_, i) => (
<Box
key={i}
sx={{
bgcolor: "rgba(245, 173, 24, 0.07)",
border: "1px solid rgba(245, 173, 24, 0.16)",
borderRadius: "2px",
}}
/>
))}
</Box>
);
export const PanelGhost = ({ title }: { title: string }) => (
<Box
sx={{

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiGet, apiPut } from "@api/apiClient";
import {
DASHBOARD_PANELS,
GRID_COLUMNS,
@ -8,6 +9,14 @@ import {
const STORAGE_KEY = "b4_dashboard_layout";
const LAYOUT_VERSION = 2;
const LAYOUT_ENDPOINT = "/api/ui/dashboard";
const SAVE_DEBOUNCE_MS = 800;
interface StoredDashboard {
order?: string[];
hidden?: string[];
spans?: Record<string, number>;
}
interface StoredLayout {
v: number;
@ -45,33 +54,63 @@ const emptyLayout = (): StoredLayout => ({
spans: {},
});
const normalize = (raw: StoredDashboard): StoredLayout => {
const spans: Record<string, number> = {};
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<StoredLayout>;
if (parsed?.v !== LAYOUT_VERSION) return emptyLayout();
const spans: Record<string, number> = {};
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<StoredLayout>(loadLayout);
const hydrated = useRef(false);
const dirty = useRef(false);
useEffect(() => {
let cancelled = false;
apiGet<StoredDashboard>(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<StoredDashboard>(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,