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 type { Net, MazeNode, Edge, DeckyNode } from './types';
|
||||||
import { useMazeApi } from './useMazeApi';
|
import { useMazeApi } from './useMazeApi';
|
||||||
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
|
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
|
||||||
|
import { useLayoutPersistor } from './useMazeLayoutStore';
|
||||||
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
|
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
|
||||||
|
|
||||||
/* Short unique suffix for default names — avoids the DB uniqueness
|
/* Short unique suffix for default names — avoids the DB uniqueness
|
||||||
@@ -43,6 +44,8 @@ const MazeNET: React.FC = () => {
|
|||||||
const [inspectorOpen, setInspectorOpen] = useState(true);
|
const [inspectorOpen, setInspectorOpen] = useState(true);
|
||||||
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
|
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
|
||||||
const [archetypes, setArchetypes] = useState<Archetype[]>(DEFAULT_ARCHETYPES);
|
const [archetypes, setArchetypes] = useState<Archetype[]>(DEFAULT_ARCHETYPES);
|
||||||
|
|
||||||
|
useLayoutPersistor(topologyId || null, nets, nodes);
|
||||||
const [loadErr, setLoadErr] = useState<string | null>(null);
|
const [loadErr, setLoadErr] = useState<string | null>(null);
|
||||||
const [actionErr, setActionErr] = useState<string | null>(null);
|
const [actionErr, setActionErr] = useState<string | null>(null);
|
||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import api from '../../utils/api';
|
|||||||
import { ARCHETYPES as DEFAULT_ARCHETYPES, DEFAULT_SERVICES } from './data';
|
import { ARCHETYPES as DEFAULT_ARCHETYPES, DEFAULT_SERVICES } from './data';
|
||||||
import type { Archetype, ServiceDef } from './data';
|
import type { Archetype, ServiceDef } from './data';
|
||||||
import type { Net, MazeNode, Edge, DeckyNode } from './types';
|
import type { Net, MazeNode, Edge, DeckyNode } from './types';
|
||||||
|
import { applyLayout, loadLayout } from './useMazeLayoutStore';
|
||||||
|
|
||||||
export interface LANRow {
|
export interface LANRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -214,7 +215,10 @@ export function useMazeApi(): MazeApi {
|
|||||||
|
|
||||||
const getTopology = useCallback(async (id: string) => {
|
const getTopology = useCallback(async (id: string) => {
|
||||||
const { data } = await api.get<TopologyDetail>(`/topologies/${id}`);
|
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 () => {
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import { Network, Plus, Power, Trash2, UploadCloud, RefreshCw } from 'lucide-react';
|
import { Network, Plus, Power, Trash2, UploadCloud, RefreshCw } from 'lucide-react';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
|
import { clearLayout } from '../MazeNET/useMazeLayoutStore';
|
||||||
import './TopologyList.css';
|
import './TopologyList.css';
|
||||||
|
|
||||||
interface TopologySummary {
|
interface TopologySummary {
|
||||||
@@ -90,6 +91,7 @@ const TopologyList: React.FC = () => {
|
|||||||
setBusy(id);
|
setBusy(id);
|
||||||
try {
|
try {
|
||||||
await api.delete(`/topologies/${id}`);
|
await api.delete(`/topologies/${id}`);
|
||||||
|
clearLayout(id);
|
||||||
await fetchRows();
|
await fetchRows();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error)?.message ?? 'delete failed');
|
setErr((e as Error)?.message ?? 'delete failed');
|
||||||
|
|||||||
Reference in New Issue
Block a user