feat(mazenet): persist canvas layout per topology to localStorage
Dragging a LAN or decky, or resizing a NetBox, updates React state but previously vanished on reload because the grid-layout adapter rewrote everything from the graph. Add a per-topology localStorage snapshot (key: mazenet.layout.<topologyId>) that captures net x/y/w/h and decky x/y; useLayoutPersistor writes it debounced, and getTopology merges it over adaptTopology's grid so entities without a stored entry still fall back to a clean auto-layout. Deleting a topology calls clearLayout to drop its snapshot.
This commit is contained in:
@@ -16,6 +16,7 @@ import type { Archetype, ServiceDef } from './data';
|
||||
import type { Net, MazeNode, Edge, DeckyNode } from './types';
|
||||
import { useMazeApi } from './useMazeApi';
|
||||
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
|
||||
import { useLayoutPersistor } from './useMazeLayoutStore';
|
||||
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
|
||||
|
||||
/* Short unique suffix for default names — avoids the DB uniqueness
|
||||
@@ -43,6 +44,8 @@ const MazeNET: React.FC = () => {
|
||||
const [inspectorOpen, setInspectorOpen] = useState(true);
|
||||
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
|
||||
const [archetypes, setArchetypes] = useState<Archetype[]>(DEFAULT_ARCHETYPES);
|
||||
|
||||
useLayoutPersistor(topologyId || null, nets, nodes);
|
||||
const [loadErr, setLoadErr] = useState<string | null>(null);
|
||||
const [actionErr, setActionErr] = useState<string | null>(null);
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
|
||||
@@ -3,6 +3,7 @@ import api from '../../utils/api';
|
||||
import { ARCHETYPES as DEFAULT_ARCHETYPES, DEFAULT_SERVICES } from './data';
|
||||
import type { Archetype, ServiceDef } from './data';
|
||||
import type { Net, MazeNode, Edge, DeckyNode } from './types';
|
||||
import { applyLayout, loadLayout } from './useMazeLayoutStore';
|
||||
|
||||
export interface LANRow {
|
||||
id: string;
|
||||
@@ -214,7 +215,10 @@ export function useMazeApi(): MazeApi {
|
||||
|
||||
const getTopology = useCallback(async (id: string) => {
|
||||
const { data } = await api.get<TopologyDetail>(`/topologies/${id}`);
|
||||
return adaptTopology(data);
|
||||
const hydrated = adaptTopology(data);
|
||||
const layout = loadLayout(id);
|
||||
const { nets, nodes } = applyLayout(hydrated.nets, hydrated.nodes, layout);
|
||||
return { ...hydrated, nets, nodes };
|
||||
}, []);
|
||||
|
||||
const getServices = useCallback(async () => {
|
||||
|
||||
111
decnet_web/src/components/MazeNET/useMazeLayoutStore.ts
Normal file
111
decnet_web/src/components/MazeNET/useMazeLayoutStore.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { Net, MazeNode } from './types';
|
||||
|
||||
/** Per-topology canvas layout persisted to localStorage. Keyed by
|
||||
* topology id so two topologies don't share positions. Stored keys
|
||||
* for missing LAN/decky ids are pruned on save (self-heal). */
|
||||
|
||||
interface NetLayout { x: number; y: number; w: number; h: number }
|
||||
interface NodeLayout { x: number; y: number }
|
||||
|
||||
export interface LayoutSnapshot {
|
||||
nets: Record<string, NetLayout>;
|
||||
nodes: Record<string, NodeLayout>;
|
||||
}
|
||||
|
||||
const EMPTY: LayoutSnapshot = { nets: {}, nodes: {} };
|
||||
const SAVE_DEBOUNCE_MS = 300;
|
||||
|
||||
function storageKey(topologyId: string): string {
|
||||
return `mazenet.layout.${topologyId}`;
|
||||
}
|
||||
|
||||
export function loadLayout(topologyId: string | null): LayoutSnapshot {
|
||||
if (!topologyId) return EMPTY;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(topologyId));
|
||||
if (!raw) return EMPTY;
|
||||
const parsed = JSON.parse(raw) as Partial<LayoutSnapshot>;
|
||||
return {
|
||||
nets: parsed.nets ?? {},
|
||||
nodes: parsed.nodes ?? {},
|
||||
};
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
function saveLayout(topologyId: string, snap: LayoutSnapshot): void {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(topologyId), JSON.stringify(snap));
|
||||
} catch {
|
||||
/* quota exhausted or private mode — layout reverts to grid. */
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply stored positions on top of grid-laid-out entities. Entities
|
||||
* without a stored entry keep their grid position. */
|
||||
export function applyLayout(
|
||||
nets: Net[],
|
||||
nodes: MazeNode[],
|
||||
layout: LayoutSnapshot,
|
||||
): { nets: Net[]; nodes: MazeNode[] } {
|
||||
const adjustedNets = nets.map((n) => {
|
||||
const saved = layout.nets[n.id];
|
||||
return saved ? { ...n, x: saved.x, y: saved.y, w: saved.w, h: saved.h } : n;
|
||||
});
|
||||
const adjustedNodes = nodes.map((n) => {
|
||||
const saved = layout.nodes[n.id];
|
||||
return saved ? { ...n, x: saved.x, y: saved.y } : n;
|
||||
});
|
||||
return { nets: adjustedNets, nodes: adjustedNodes };
|
||||
}
|
||||
|
||||
/** Debounced writer — every nets/nodes change is captured and flushed
|
||||
* to localStorage after a short idle window. Also prunes entries for
|
||||
* LANs / deckies that no longer exist in the current topology. */
|
||||
export function useLayoutPersistor(
|
||||
topologyId: string | null,
|
||||
nets: Net[],
|
||||
nodes: MazeNode[],
|
||||
): void {
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!topologyId) return;
|
||||
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
const snap: LayoutSnapshot = { nets: {}, nodes: {} };
|
||||
for (const n of nets) {
|
||||
if (n.kind === 'internet') continue;
|
||||
snap.nets[n.id] = { x: n.x, y: n.y, w: n.w, h: n.h };
|
||||
}
|
||||
for (const n of nodes) {
|
||||
snap.nodes[n.id] = { x: n.x, y: n.y };
|
||||
}
|
||||
saveLayout(topologyId, snap);
|
||||
timerRef.current = null;
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [topologyId, nets, nodes]);
|
||||
}
|
||||
|
||||
/** Clear the stored layout for a topology — call after delete so stale
|
||||
* entries don't linger forever. */
|
||||
export function clearLayout(topologyId: string): void {
|
||||
try {
|
||||
window.localStorage.removeItem(storageKey(topologyId));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Hook form for consumers that prefer a stable callback. */
|
||||
export function useClearLayout(): (topologyId: string) => void {
|
||||
return useCallback((id: string) => clearLayout(id), []);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Network, Plus, Power, Trash2, UploadCloud, RefreshCw } from 'lucide-react';
|
||||
import api from '../../utils/api';
|
||||
import { clearLayout } from '../MazeNET/useMazeLayoutStore';
|
||||
import './TopologyList.css';
|
||||
|
||||
interface TopologySummary {
|
||||
@@ -90,6 +91,7 @@ const TopologyList: React.FC = () => {
|
||||
setBusy(id);
|
||||
try {
|
||||
await api.delete(`/topologies/${id}`);
|
||||
clearLayout(id);
|
||||
await fetchRows();
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? 'delete failed');
|
||||
|
||||
Reference in New Issue
Block a user