diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index 701838ea..a15aa5fa 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -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(DEFAULT_SERVICES); const [archetypes, setArchetypes] = useState(DEFAULT_ARCHETYPES); + + useLayoutPersistor(topologyId || null, nets, nodes); const [loadErr, setLoadErr] = useState(null); const [actionErr, setActionErr] = useState(null); const [deploying, setDeploying] = useState(false); diff --git a/decnet_web/src/components/MazeNET/useMazeApi.ts b/decnet_web/src/components/MazeNET/useMazeApi.ts index fd85d448..61c4756b 100644 --- a/decnet_web/src/components/MazeNET/useMazeApi.ts +++ b/decnet_web/src/components/MazeNET/useMazeApi.ts @@ -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(`/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 () => { diff --git a/decnet_web/src/components/MazeNET/useMazeLayoutStore.ts b/decnet_web/src/components/MazeNET/useMazeLayoutStore.ts new file mode 100644 index 00000000..4bef950c --- /dev/null +++ b/decnet_web/src/components/MazeNET/useMazeLayoutStore.ts @@ -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; + nodes: Record; +} + +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; + 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(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), []); +} diff --git a/decnet_web/src/components/TopologyList/TopologyList.tsx b/decnet_web/src/components/TopologyList/TopologyList.tsx index 2821c088..e7d79c26 100644 --- a/decnet_web/src/components/TopologyList/TopologyList.tsx +++ b/decnet_web/src/components/TopologyList/TopologyList.tsx @@ -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');