import { useEffect, useCallback, useRef, useState } from 'react' import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react' import { type Node } from '@xyflow/react' import { applyDagreLayout } from '@/utils/layout' import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, migrateClusterHandles, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { generateUUID } from '@/utils/uuid' import { getCenteredPosition } from '@/utils/viewportCenter' import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent' import { generateMarkdownTable } from '@/utils/exportMarkdown' import { copyToClipboard } from '@/utils/clipboard' import { getDesignIdFromUrl, setDesignIdInUrl } from '@/utils/designUrl' import { ExportModal } from '@/components/modals/ExportModal' import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml' import { parseYamlToCanvas } from '@/utils/importYaml' import { TooltipProvider } from '@/components/ui/tooltip' import { Toaster } from '@/components/ui/sonner' import { toast } from 'sonner' import { CanvasContainer } from '@/components/canvas/CanvasContainer' import { Sidebar } from '@/components/panels/Sidebar' import { Toolbar } from '@/components/panels/Toolbar' import { DetailPanel } from '@/components/panels/DetailPanel' import { LoginPage } from '@/components/LoginPage' import { NodeModal } from '@/components/modals/NodeModal' import { EdgeModal } from '@/components/modals/EdgeModal' import { ScanConfigModal } from '@/components/modals/ScanConfigModal' import { SettingsModal } from '@/components/modals/SettingsModal' import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal' import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal' import { ProxmoxImportModal } from '@/components/proxmox/ProxmoxImportModal' import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal' import { TextModal, type TextFormData } from '@/components/modals/TextModal' import { ThemeModal } from '@/components/modals/ThemeModal' import { CustomStyleModal } from '@/components/modals/CustomStyleModal' import { SearchModal } from '@/components/modals/SearchModal' import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal' import { ShortcutsModal } from '@/components/modals/ShortcutsModal' import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal' import { useCanvasStore } from '@/stores/canvasStore' import { useDesignStore } from '@/stores/designStore' import { useAuthStore } from '@/stores/authStore' import { useThemeStore } from '@/stores/themeStore' import { canvasApi, designsApi, liveviewApi } from '@/api/client' import * as standaloneStorage from '@/utils/standaloneStorage' import { demoNodes, demoEdges } from '@/utils/demoData' import { useStatusPolling } from '@/hooks/useStatusPolling' import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types' import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types' import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types' import type { ProxmoxNode, ProxmoxEdge } from '@/components/proxmox/types' import { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' export default function App() { const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore() useStatusPolling() const [themeModalOpen, setThemeModalOpen] = useState(false) const [styleEditorType, setStyleEditorType] = useState(null) const [searchOpen, setSearchOpen] = useState(false) const [scanHistoryOpen, setScanHistoryOpen] = useState(false) const [pendingModalOpen, setPendingModalOpen] = useState(false) const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending') const [pendingHighlightId, setPendingHighlightId] = useState(undefined) const openPendingModal = useCallback((deviceId?: string, status: 'pending' | 'hidden' = 'pending') => { setPendingHighlightId(undefined) setPendingModalStatus(status) setPendingModalOpen(true) if (deviceId) setTimeout(() => setPendingHighlightId(deviceId), 0) }, []) const [shortcutsOpen, setShortcutsOpen] = useState(false) const [addNodeOpen, setAddNodeOpen] = useState(false) const [addGroupRectOpen, setAddGroupRectOpen] = useState(false) const [addTextOpen, setAddTextOpen] = useState(false) const [editNodeId, setEditNodeId] = useState(null) const [pendingConnection, setPendingConnection] = useState(null) const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null) const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeId: string; containerId: string } | null>(null) const [editEdgeId, setEditEdgeId] = useState(null) const [scanConfigOpen, setScanConfigOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) const [exportModalOpen, setExportModalOpen] = useState(false) const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false) const [zwaveImportOpen, setZwaveImportOpen] = useState(false) const [proxmoxImportOpen, setProxmoxImportOpen] = useState(false) // Declare handleSave before the Ctrl+S effect so it is in scope. // Returns true on success, false on failure — the design-switch effect relies // on this to avoid loading (and clobbering) the canvas when a save fails. const handleSave = useCallback(async (designIdOverride?: string): Promise => { try { const saveDesignId = designIdOverride ?? activeDesignId if (STANDALONE) { if (!saveDesignId) return false // Floor plans are backend-only (upload/serve), so standalone never persists one. standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle }) markSaved() toast.success('Canvas saved') return true } const nodesToSave = nodes.map(serializeNode) const edgesToSave = edges.map(serializeEdge) const viewport: Record = { theme_id: activeTheme } if (floorMap) viewport.floor_map = floorMap await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport, custom_style: customStyle, design_id: saveDesignId }) markSaved() toast.success('Canvas saved') return true } catch { toast.error('Save failed') return false } }, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId, floorMap]) // Keep a ref so the keydown handler always calls the latest version const handleSaveRef = useRef(handleSave) useEffect(() => { handleSaveRef.current = handleSave }, [handleSave]) const loadCanvasFromApi = useCallback(async (designId?: string) => { try { const res = await canvasApi.load(designId) const { nodes: apiNodes, edges: apiEdges } = res.data if (apiNodes.length > 0) { const proxmoxContainerMap = new Map( (apiNodes as ApiNode[]) .filter((n) => n.type === 'group' || n.container_mode === true) .map((n) => [n.id, true]) ) const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles( (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)), (apiEdges as ApiEdge[]).map(deserializeApiEdge), ) const savedTheme = res.data.viewport?.theme_id if (savedTheme) setTheme(savedTheme) if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined // Clear when the target design has no floor plan, so it doesn't bleed // across canvases when switching designs. setFloorMap(savedFloorMap ?? null) loadCanvas(rfNodes, rfEdges) } else { setFloorMap(null) loadCanvas(demoNodes, demoEdges) } } catch { setFloorMap(null) loadCanvas(demoNodes, demoEdges) } }, [loadCanvas, setTheme, setCustomStyle, setFloorMap]) // Standalone counterpart of loadCanvasFromApi — reads a design's canvas from // localStorage, falling back to the demo canvas when it has never been saved. const loadStandaloneCanvas = useCallback((designId: string) => { const saved = standaloneStorage.loadCanvas(designId) if (saved && saved.nodes.length > 0) { if (saved.theme_id) setTheme(saved.theme_id) if (saved.custom_style) setCustomStyle(saved.custom_style) // Floor plans are backend-only; keep the store clear in standalone mode. setFloorMap(null) const migrated = migrateClusterHandles(saved.nodes, saved.edges) loadCanvas(migrated.nodes, migrated.edges) } else { setFloorMap(null) loadCanvas(demoNodes, demoEdges) } }, [loadCanvas, setTheme, setCustomStyle, setFloorMap]) const loadDesignsAndCanvas = useCallback(async () => { // Prefer a design id explicitly requested via the URL (?design=), so a // refresh or shared link opens that design. Ignore it when it doesn't match // a known design and fall back to the current/default one. const urlDesignId = getDesignIdFromUrl() if (STANDALONE) { const designs = standaloneStorage.ensureSeed() setDesigns(designs) const fromUrl = urlDesignId && designs.some((d) => d.id === urlDesignId) ? urlDesignId : null const targetId = fromUrl ?? activeDesignId ?? designs[0]?.id if (targetId) { setActiveDesign(targetId) loadStandaloneCanvas(targetId) } return } try { const res = await designsApi.list() const loadedDesigns = res.data setDesigns(loadedDesigns) const fromUrl = urlDesignId && loadedDesigns.some((d) => d.id === urlDesignId) ? urlDesignId : null const targetId = fromUrl ?? activeDesignId ?? loadedDesigns[0]?.id if (targetId) { setActiveDesign(targetId) await loadCanvasFromApi(targetId) } } catch { // If API fails (e.g. fresh DB with no designs), fall back to demo data loadCanvas(demoNodes, demoEdges) } }, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId, loadCanvas]) // Keep a ref so the auth effect can call the latest loader without listing it // as a dependency (which would re-fire on every design switch). const loadDesignsAndCanvasRef = useRef(loadDesignsAndCanvas) useEffect(() => { loadDesignsAndCanvasRef.current = loadDesignsAndCanvas }, [loadDesignsAndCanvas]) // Load designs + canvas on auth (or immediately in standalone mode, which has // no auth gate). useEffect(() => { if (STANDALONE) { loadDesignsAndCanvasRef.current() return } if (!isAuthenticated) return loadDesignsAndCanvasRef.current() }, [isAuthenticated]) // only on auth change, not design change // Reload canvas when active design changes (after initial load) const initialLoadDone = useRef(false) const prevDesignRef = useRef(null) // Set while we programmatically revert activeDesignId after a failed save, so // the re-entrant effect run skips save/load and just re-syncs the refs. const revertingRef = useRef(false) useEffect(() => { if (revertingRef.current) { revertingRef.current = false prevDesignRef.current = activeDesignId return } // Standalone has no auth gate; backed mode requires authentication. const ready = STANDALONE || isAuthenticated const loadForDesign = STANDALONE ? loadStandaloneCanvas : loadCanvasFromApi if (ready && activeDesignId && initialLoadDone.current) { const oldId = prevDesignRef.current // If the previous design was deleted (no longer in the list), don't try to // save into it — just load the newly-selected design. const oldStillExists = oldId ? useDesignStore.getState().designs.some((d) => d.id === oldId) : false if (oldId && oldId !== activeDesignId && oldStillExists) { // Save current (old) canvas data under the old design ID before switching. // We call handleSave directly (not via ref) so it runs in this effect's // closure where activeDesignId is already the NEW value — the override // ensures data is stored under the correct design_id. const targetId = activeDesignId handleSave(oldId).then((ok) => { if (ok) { loadForDesign(targetId) } else { // Save failed: don't load the new design — that would overwrite the // unsaved in-memory canvas. Revert the selection back to the old // design so the UI matches the data still on screen. toast.error('Switch cancelled — unsaved changes kept') revertingRef.current = true setActiveDesign(oldId) } }) } else { loadForDesign(activeDesignId) } } if (activeDesignId) { prevDesignRef.current = activeDesignId initialLoadDone.current = true } }, [activeDesignId]) // Reflect the active design into the URL so refresh/share reopens it. useEffect(() => { if (activeDesignId) setDesignIdInUrl(activeDesignId) }, [activeDesignId]) // Keep refs for store actions so keydown handler is always up-to-date without re-registering const undoRef = useRef(undo) const redoRef = useRef(redo) useEffect(() => { undoRef.current = undo }, [undo]) useEffect(() => { redoRef.current = redo }, [redo]) // Global keyboard shortcuts useEffect(() => { const handler = (e: KeyboardEvent) => { const ctrl = e.ctrlKey || e.metaKey // Ignore shortcuts when typing in an input/textarea const tag = (e.target as HTMLElement).tagName const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable if (ctrl && e.key === 's') { e.preventDefault(); handleSaveRef.current(); return } if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return } if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return } if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return } // Copy/paste (Ctrl/Cmd+C/V) handled in CanvasContainer so paste can place // nodes under the cursor / viewport center. if (e.key === '?' && !isInput) { setShortcutsOpen(true); return } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, []) const handleAddNode = useCallback((data: Partial) => { snapshotHistory() const id = generateUUID() const isContainerNode = data.container_mode === true const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null // Only nest when the parent is an actual container. For a non-container // parent the LXC/VM stays a free node (linked by a virtual edge) — setting // extent:'parent' on a non-container would trap it inside the parent's tiny // bounding box with no way to drag it out (issue #205 follow-up). const nestInParent = !!parentNode?.data.container_mode // Seed an ABSOLUTE position near the container's top-left; addNode converts // it to container-relative. addNode is the single authority for parentId / // extent, so we don't set them here. const position = nestInParent && parentNode ? { x: parentNode.position.x + 20, y: parentNode.position.y + 50 } : getCenteredPosition(isContainerNode ? 300 : 0, isContainerNode ? 200 : 0) const newNode: Node = { id, type: data.type ?? 'generic', position, data: { status: 'unknown', services: [], ...data } as NodeData, ...(isContainerNode ? { width: 300, height: 200 } : {}), } addNode(newNode) toast.success(`Added "${data.label}"`) }, [addNode, nodes, snapshotHistory]) const handleAddGroupRect = useCallback((data: GroupRectFormData) => { snapshotHistory() const id = generateUUID() const newNode: Node = { id, type: 'groupRect', position: getCenteredPosition(360, 240), data: { label: data.label, type: 'groupRect', status: 'unknown', services: [], custom_colors: { border: data.border_color, border_style: data.border_style, border_width: data.border_width, background: data.background_color, text_color: data.text_color, text_position: data.text_position, text_size: data.text_size, label_position: data.label_position, font: data.font, z_order: data.z_order, }, }, width: 360, height: 240, zIndex: data.z_order - 10, } addNode(newNode) }, [addNode, snapshotHistory]) const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => { if (!editingGroupRectId) return snapshotHistory() const existing = nodes.find((n) => n.id === editingGroupRectId) updateNode(editingGroupRectId, { label: data.label, custom_colors: { ...existing?.data.custom_colors, border: data.border_color, border_style: data.border_style, border_width: data.border_width, background: data.background_color, text_color: data.text_color, text_position: data.text_position, text_size: data.text_size, label_position: data.label_position, font: data.font, z_order: data.z_order, }, }) setNodeZIndex(editingGroupRectId, data.z_order - 10) setEditingGroupRectId(null) }, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory]) const handleAddText = useCallback((data: TextFormData) => { snapshotHistory() const id = generateUUID() const newNode: Node = { id, // Text lives in `label` because the API serializer only persists top-level // node fields; text_content is not in the schema and was lost on reload. // TextNode and the edit modal both already fall back to label. type: 'text', position: getCenteredPosition(200, 60), data: { label: data.text, type: 'text', status: 'unknown', services: [], custom_colors: { border: data.border_color, border_style: data.border_style, border_width: data.border_width, background: data.background_color, text_color: data.text_color, text_size: data.text_size, font: data.font, }, }, width: 200, height: 60, } addNode(newNode) }, [addNode, snapshotHistory]) const handleUpdateText = useCallback((data: TextFormData) => { if (!editingTextId) return snapshotHistory() const existing = nodes.find((n) => n.id === editingTextId) updateNode(editingTextId, { label: data.text, // Clear stale text_content if present from older builds — label is the // source of truth now. text_content: undefined, custom_colors: { ...existing?.data.custom_colors, border: data.border_color, border_style: data.border_style, border_width: data.border_width, background: data.background_color, text_color: data.text_color, text_size: data.text_size, font: data.font, }, }) setEditingTextId(null) }, [editingTextId, nodes, updateNode, setEditingTextId, snapshotHistory]) const handleDeleteText = useCallback(() => { if (!editingTextId) return snapshotHistory() deleteNode(editingTextId) setEditingTextId(null) }, [editingTextId, deleteNode, setEditingTextId, snapshotHistory]) const handleDeleteGroupRect = useCallback(() => { if (!editingGroupRectId) return snapshotHistory() deleteNode(editingGroupRectId) setEditingGroupRectId(null) }, [editingGroupRectId, deleteNode, setEditingGroupRectId, snapshotHistory]) const handleEditNode = useCallback((id: string) => { setEditNodeId(id) }, []) const handleUpdateNode = useCallback((data: Partial) => { if (!editNodeId) return snapshotHistory() const existingNode = nodes.find((n) => n.id === editNodeId) updateNode(editNodeId, data) // If container_mode changed, apply structural changes (children parentId, node dimensions) if (typeof data.container_mode === 'boolean') { setProxmoxContainerMode(editNodeId, data.container_mode) } // Sync virtual edge when parent_id changes on an LXC/VM node const nodeType = data.type ?? existingNode?.data.type if ((nodeType === 'lxc' || nodeType === 'vm' || nodeType === 'docker_container') && 'parent_id' in data) { const oldParentId = existingNode?.data.parent_id ?? null const newParentId = data.parent_id ?? null if (oldParentId !== newParentId) { // Remove any existing virtual edge between child and old parent if (oldParentId) { const oldEdge = edges.find((e) => e.data?.type === 'virtual' && ((e.source === editNodeId && e.target === oldParentId) || (e.source === oldParentId && e.target === editNodeId)) ) if (oldEdge) deleteEdge(oldEdge.id) } // Create virtual edge only when parent is NOT in container mode // (container mode shows containment visually — no edge needed) if (newParentId) { const parentNode = nodes.find((n) => n.id === newParentId) if (!parentNode?.data.container_mode) { onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection) } } } } setEditNodeId(null) }, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect, snapshotHistory]) const handleAutoLayout = useCallback(() => { const laid = applyDagreLayout(nodes, edges) loadCanvas(laid, edges) toast.success('Canvas auto-arranged') }, [nodes, edges, loadCanvas]) const handleExportMd = useCallback(async () => { const md = generateMarkdownTable(nodes) if (!md) { toast.error('No nodes to export'); return } if (await copyToClipboard(md)) { toast.success('Markdown table copied to clipboard') } else { toast.error('Markdown copy failed') } }, [nodes]) const handleExportYaml = useCallback(() => { if (nodes.length === 0) { toast.error('No nodes to export'); return } const content = exportCanvasToYaml(nodes, edges) downloadYaml(content) toast.success('Canvas exported as YAML') }, [nodes, edges]) const handleImportYaml = useCallback((content: string) => { try { const { nodes: merged, edges: mergedEdges, imported } = parseYamlToCanvas(content, nodes, edges) snapshotHistory() loadCanvas(merged, mergedEdges) markUnsaved() toast.success(`Imported ${imported} node${imported !== 1 ? 's' : ''}`) } catch (err) { toast.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`) } }, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved]) // Open the read-only live view of the currently active design in a new tab. // Standalone has no backend/key — it reads localStorage, so just open /view. // Otherwise fetch the configured live view key and build /view?key=...&design=. const handleViewOnly = useCallback(async () => { if (STANDALONE) { // Standalone reads canvas from localStorage; pass the active design id so // the read-only tab renders the same canvas the user is viewing. const url = activeDesignId ? `/view?design=${encodeURIComponent(activeDesignId)}` : '/view' window.open(url, '_blank', 'noopener,noreferrer') return } try { const res = await liveviewApi.getConfig() if (!res.data.enabled || !res.data.key) { toast.error('Live view is disabled — set LIVEVIEW_KEY in the backend .env') return } const params = new URLSearchParams({ key: res.data.key }) if (activeDesignId) params.set('design', activeDesignId) window.open(`/view?${params.toString()}`, '_blank', 'noopener,noreferrer') } catch { toast.error('Failed to open live view') } }, [activeDesignId]) const handleExport = useCallback(() => { const el = canvasRef.current?.querySelector('.react-flow') if (!el) { toast.error('Canvas not ready'); return } setExportModalOpen(true) }, []) const handleZigbeeAddToCanvas = useCallback((zigbeeNodes: ZigbeeNode[], zigbeeEdges: ZigbeeEdge[]) => { snapshotHistory() // Place nodes in a grid centred on the visible canvas. const COLS = 4 const SPACING_X = 170 const SPACING_Y = 100 const cols = Math.min(COLS, zigbeeNodes.length) const rows = Math.ceil(zigbeeNodes.length / COLS) const origin = getCenteredPosition(cols * SPACING_X, rows * SPACING_Y) zigbeeNodes.forEach((zn, i) => { const id = zn.id const col = i % COLS const row = Math.floor(i / COLS) const position = { x: origin.x + col * SPACING_X, y: origin.y + row * SPACING_Y } const newNode: import('@xyflow/react').Node = { id, type: zn.type, position, data: { label: zn.friendly_name, type: zn.type as NodeData['type'], status: 'unknown' as const, services: [], ...(zn.lqi != null ? { properties: [{ key: 'LQI', value: String(zn.lqi), icon: 'signal', visible: true }] } : {}), ...(zn.model ? { os: zn.model } : {}), ...(zn.parent_id ? { parent_id: zn.parent_id } : {}), }, } addNode(newNode) }) // Add IoT edges between Zigbee devices: parent bottom -> child top zigbeeEdges.forEach((ze) => { onConnect({ source: ze.source, sourceHandle: 'bottom', target: ze.target, targetHandle: 'top-t', type: 'iot', } as unknown as import('@xyflow/react').Connection) }) // Auto-select only the freshly imported nodes so the user can drag the // whole subtree as a group. const importedIds = new Set(zigbeeNodes.map((zn) => zn.id)) useCanvasStore.setState((state) => ({ nodes: state.nodes.map((n) => ({ ...n, selected: importedIds.has(n.id) })), selectedNodeIds: Array.from(importedIds), selectedNodeId: importedIds.size === 1 ? Array.from(importedIds)[0] : null, })) markUnsaved() }, [addNode, onConnect, snapshotHistory, markUnsaved]) const handleZwaveAddToCanvas = useCallback((zwaveNodes: ZwaveNode[], zwaveEdges: ZwaveEdge[]) => { snapshotHistory() const COLS = 4 const SPACING_X = 170 const SPACING_Y = 100 const cols = Math.min(COLS, zwaveNodes.length) const rows = Math.ceil(zwaveNodes.length / COLS) const origin = getCenteredPosition(cols * SPACING_X, rows * SPACING_Y) zwaveNodes.forEach((zn, i) => { const id = zn.id const col = i % COLS const row = Math.floor(i / COLS) const position = { x: origin.x + col * SPACING_X, y: origin.y + row * SPACING_Y } const newNode: import('@xyflow/react').Node = { id, type: zn.type, position, data: { label: zn.friendly_name, type: zn.type as NodeData['type'], status: 'unknown' as const, services: [], ...(zn.model ? { os: zn.model } : {}), ...(zn.parent_id ? { parent_id: zn.parent_id } : {}), }, } addNode(newNode) }) // Add IoT edges between Z-Wave devices: parent bottom -> child top zwaveEdges.forEach((ze) => { onConnect({ source: ze.source, sourceHandle: 'bottom', target: ze.target, targetHandle: 'top-t', type: 'iot', } as unknown as import('@xyflow/react').Connection) }) const importedIds = new Set(zwaveNodes.map((zn) => zn.id)) useCanvasStore.setState((state) => ({ nodes: state.nodes.map((n) => ({ ...n, selected: importedIds.has(n.id) })), selectedNodeIds: Array.from(importedIds), selectedNodeId: importedIds.size === 1 ? Array.from(importedIds)[0] : null, })) markUnsaved() }, [addNode, onConnect, snapshotHistory, markUnsaved]) const handleProxmoxAddToCanvas = useCallback((pmNodes: ProxmoxNode[], pmEdges: ProxmoxEdge[]) => { snapshotHistory() const COLS = 4 const SPACING_X = 190 const SPACING_Y = 110 const cols = Math.min(COLS, pmNodes.length) const rows = Math.ceil(pmNodes.length / COLS) const origin = getCenteredPosition(cols * SPACING_X, rows * SPACING_Y) // Multiple hosts from one import = a cluster → chain them via left/right // 'cluster' edges. Those endpoints need one left + one right handle each // (both default to 0), so grant them to the host nodes up front. const clusterEdges = buildProxmoxClusterEdges(pmNodes) const cluster = clusterEdges.length > 0 pmNodes.forEach((pn, i) => { const col = i % COLS const row = Math.floor(i / COLS) const position = { x: origin.x + col * SPACING_X, y: origin.y + row * SPACING_Y } const isClusterHost = cluster && pn.type === 'proxmox' const newNode: import('@xyflow/react').Node = { id: pn.id, type: pn.type, position, data: { label: pn.label, type: pn.type as NodeData['type'], status: (pn.status === 'online' ? 'online' : 'unknown') as NodeData['status'], services: [], ...(pn.ip ? { ip: pn.ip } : {}), ...(pn.hostname ? { hostname: pn.hostname } : {}), ...(isClusterHost ? { left_handles: 1, right_handles: 1 } : {}), }, } addNode(newNode) }) // Host → guest links render as 'virtual' edges (VM/LXC ↔ host). pmEdges.forEach((pe) => { onConnect({ source: pe.source, sourceHandle: 'bottom', target: pe.target, targetHandle: 'top-t', type: 'virtual', } as unknown as import('@xyflow/react').Connection) }) // Host ↔ host links render as 'cluster' edges (left → right chain). clusterEdges.forEach((ce) => { onConnect({ source: ce.source, sourceHandle: ce.sourceHandle, target: ce.target, targetHandle: ce.targetHandle, type: 'cluster', } as unknown as import('@xyflow/react').Connection) }) const importedIds = new Set(pmNodes.map((pn) => pn.id)) useCanvasStore.setState((state) => ({ nodes: state.nodes.map((n) => ({ ...n, selected: importedIds.has(n.id) })), selectedNodeIds: Array.from(importedIds), selectedNodeId: importedIds.size === 1 ? Array.from(importedIds)[0] : null, })) markUnsaved() }, [addNode, onConnect, snapshotHistory, markUnsaved]) const handleEdgeConnect = useCallback((connection: Connection) => { setPendingConnection(connection) }, []) const handleEdgeConfirm = useCallback((edgeData: EdgeData) => { if (!pendingConnection) return snapshotHistory() onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection) // When a virtual edge is drawn between a child node and a container node, sync parent_id if (edgeData.type === 'virtual') { const src = nodes.find((n) => n.id === pendingConnection.source) const tgt = nodes.find((n) => n.id === pendingConnection.target) if (src && tgt) { const assignment = resolveVirtualEdgeParent( { id: src.id, type: src.data.type as NodeData['type'] }, { id: tgt.id, type: tgt.data.type as NodeData['type'] }, ) if (assignment) { updateNode(assignment.childId, { parent_id: assignment.parentId }) } } } setPendingConnection(null) }, [pendingConnection, onConnect, nodes, updateNode, snapshotHistory]) const handleEdgeDoubleClick = useCallback((edge: Edge) => { setEditEdgeId(edge.id) }, []) const handleNodeDoubleClick = useCallback((node: Node) => { // 'group' uses inline rename (pencil button in header). Opening the // generic NodeModal would clobber the group's height (via the // properties-clears-height rule in updateNode) and lose its children. // 'groupRect' has its own onDoubleClick that already routes to GroupRectModal. if (node.data.type === 'group' || node.data.type === 'groupRect') return handleEditNode(node.id) }, [handleEditNode]) const handleEdgeUpdate = useCallback((data: EdgeData) => { if (!editEdgeId) return snapshotHistory() updateEdge(editEdgeId, data) setEditEdgeId(null) }, [editEdgeId, updateEdge, snapshotHistory]) const handleEdgeDelete = useCallback(() => { if (!editEdgeId) return snapshotHistory() deleteEdge(editEdgeId) setEditEdgeId(null) }, [editEdgeId, deleteEdge, snapshotHistory]) const handleClearWaypoints = useCallback(() => { if (!editEdgeId) return snapshotHistory() updateEdge(editEdgeId, { waypoints: [] }) setEditEdgeId(null) }, [editEdgeId, updateEdge, snapshotHistory]) const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null if (!STANDALONE && !isAuthenticated) return return (
setAddNodeOpen(true)} onAddGroupRect={() => setAddGroupRectOpen(true)} onAddText={() => setAddTextOpen(true)} onScan={() => setScanConfigOpen(true)} onZigbeeImport={() => setZigbeeImportOpen(true)} onZwaveImport={() => setZwaveImportOpen(true)} onProxmoxImport={() => setProxmoxImportOpen(true)} onSave={handleSave} onOpenSettings={() => setSettingsOpen(true)} onOpenHistory={() => setScanHistoryOpen(true)} onOpenPending={openPendingModal} />
setThemeModalOpen(true)} onUndo={undo} onRedo={redo} onShortcuts={() => setShortcutsOpen(true)} onExportMd={handleExportMd} onExportYaml={handleExportYaml} onImportYaml={handleImportYaml} onViewOnly={handleViewOnly} />
openPendingModal(deviceId)} />
{(selectedNodeId || selectedNodeIds.length > 1) && }
setAddNodeOpen(false)} onSubmit={handleAddNode} title="Add Node" parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))} onEditTypeStyle={setStyleEditorType} /> {/* key forces re-mount when editing a different node, resetting form state */} setEditNodeId(null)} onSubmit={handleUpdateNode} initial={editNode?.data} title="Edit Node" parentCandidates={(() => { const descendants = new Set() if (editNodeId) { const queue = [editNodeId] while (queue.length) { const id = queue.shift()! for (const n of nodes) { if (n.data.parent_id === id && !descendants.has(n.id)) { descendants.add(n.id) queue.push(n.id) } } } } return nodes .filter((n) => !descendants.has(n.id)) .map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode })) })()} currentNodeId={editNodeId ?? undefined} onEditTypeStyle={setStyleEditorType} /> setPendingConnection(null)} onSubmit={handleEdgeConfirm} /> setEditEdgeId(null)} onSubmit={handleEdgeUpdate} onDelete={handleEdgeDelete} onClearWaypoints={handleClearWaypoints} initial={editEdge?.data} title="Edit Link" /> {!STANDALONE && ( setScanConfigOpen(false)} onScanNow={() => { toast.success('Network scan started — check Scan History for results') }} /> )} {!STANDALONE && ( setZigbeeImportOpen(false)} onAddToCanvas={handleZigbeeAddToCanvas} onPendingImported={() => { toast.success('Zigbee import started — check Scan History for results') }} /> )} {!STANDALONE && ( setZwaveImportOpen(false)} onAddToCanvas={handleZwaveAddToCanvas} onPendingImported={() => { toast.success('Z-Wave import started — check Scan History for results') }} /> )} {!STANDALONE && ( setProxmoxImportOpen(false)} onAddToCanvas={handleProxmoxAddToCanvas} onPendingImported={() => { toast.success('Proxmox import started — check Scan History for results') }} /> )} {!STANDALONE && ( setScanHistoryOpen(false)} /> )} setAddGroupRectOpen(false)} onSubmit={handleAddGroupRect} title="Add Zone" /> {/* key forces re-mount when editing a different rect */} setEditingGroupRectId(null)} onSubmit={handleUpdateGroupRect} onDelete={handleDeleteGroupRect} initial={(() => { const n = editingGroupRectId ? nodes.find((nd) => nd.id === editingGroupRectId) : null if (!n) return undefined const rc = n.data.custom_colors ?? {} return { label: n.data.label, font: rc.font ?? 'inter', text_color: rc.text_color ?? '#e6edf3', text_position: rc.text_position ?? 'top-left', border_color: rc.border ?? '#00d4ff', border_style: rc.border_style ?? 'solid', border_width: rc.border_width ?? 2, background_color: rc.background ?? '#00d4ff0d', text_size: rc.text_size ?? 12, label_position: rc.label_position ?? 'inside', z_order: rc.z_order ?? 1, } })()} title="Edit Zone" /> setAddTextOpen(false)} onSubmit={handleAddText} title="Add Text" /> setEditingTextId(null)} onSubmit={handleUpdateText} onDelete={handleDeleteText} initial={(() => { const n = editingTextId ? nodes.find((nd) => nd.id === editingTextId) : null if (!n) return undefined const rc = n.data.custom_colors ?? {} return { text: n.data.text_content ?? n.data.label ?? '', font: rc.font ?? 'inter', text_color: rc.text_color ?? '#e6edf3', text_size: rc.text_size ?? 14, border_color: rc.border ?? '#30363d', border_style: (rc.border_style ?? 'none') as TextFormData['border_style'], border_width: rc.border_width ?? 1, background_color: rc.background ?? '#00000000', } })()} title="Edit Text" /> {/* key forces re-mount on open so useState captures current theme as original */} setThemeModalOpen(false)} /> {/* Standalone Custom Style editor, opened from a node's Appearance shortcut with that node's type preselected. */} setStyleEditorType(null)} /> setSearchOpen(false)} onOpenPending={(deviceId) => openPendingModal(deviceId)} /> setShortcutsOpen(false)} /> n.id === pendingGroupAdd.nodeId)?.data.label ?? '') : ''} targetLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.groupId)?.data.label ?? '') : ''} onConfirm={() => { if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId) setPendingGroupAdd(null) }} onCancel={() => setPendingGroupAdd(null)} /> n.id === pendingContainerAdd.nodeId)?.data.label ?? '') : ''} targetLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.containerId)?.data.label ?? '') : ''} onConfirm={() => { if (pendingContainerAdd) addToContainer(pendingContainerAdd.containerId, pendingContainerAdd.nodeId) setPendingContainerAdd(null) }} onCancel={() => setPendingContainerAdd(null)} /> {/* Mounted in standalone too: status-check settings are hidden inside, but canvas prefs (snap, hide-IP) still apply. */} setSettingsOpen(false)} /> setPendingModalOpen(false)} highlightId={pendingHighlightId} initialStatus={pendingModalStatus} /> setExportModalOpen(false)} getElement={() => canvasRef.current?.querySelector('.react-flow') ?? null} />
) }