6fba0cdec4
Adding an LXC/VM with a parent_id pointed at a Proxmox node that is NOT in container_mode set extent:'parent' anyway, confining the node to the parent's ~140px bounding box with no way to drag it out. Nesting is now gated on the parent's container_mode in both places: App.handleAddNode no longer sets parentId/extent itself (it seeds an absolute position and lets addNode decide), and addNode strips any stray parentId/extent when it isn't nesting. New children of a real container still land at the container's top-left. ha-relevant: yes
868 lines
35 KiB
TypeScript
868 lines
35 KiB
TypeScript
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, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
|
import { generateUUID } from '@/utils/uuid'
|
|
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
|
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 { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
|
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
|
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 { demoNodes, demoEdges } from '@/utils/demoData'
|
|
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
|
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
|
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
|
|
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
|
|
|
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 } = useCanvasStore()
|
|
const canvasRef = useRef<HTMLDivElement>(null)
|
|
const { isAuthenticated } = useAuthStore()
|
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
|
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
|
|
|
|
useStatusPolling()
|
|
|
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
|
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<string | undefined>(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<string | null>(null)
|
|
const [pendingConnection, setPendingConnection] = useState<Connection | null>(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<string | null>(null)
|
|
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
const [exportModalOpen, setExportModalOpen] = useState(false)
|
|
const [zigbeeImportOpen, setZigbeeImportOpen] = 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<boolean> => {
|
|
try {
|
|
const saveDesignId = designIdOverride ?? activeDesignId
|
|
if (STANDALONE) {
|
|
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ 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)
|
|
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, 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])
|
|
|
|
// 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<string, boolean>(
|
|
(apiNodes as ApiNode[])
|
|
.filter((n) => n.type === 'group' || n.container_mode === true)
|
|
.map((n) => [n.id, true])
|
|
)
|
|
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
|
const rfEdges = (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)
|
|
loadCanvas(rfNodes, rfEdges)
|
|
} else {
|
|
loadCanvas(demoNodes, demoEdges)
|
|
}
|
|
} catch {
|
|
loadCanvas(demoNodes, demoEdges)
|
|
}
|
|
}, [loadCanvas, setTheme, setCustomStyle])
|
|
|
|
const loadDesignsAndCanvas = useCallback(async () => {
|
|
if (STANDALONE) return
|
|
try {
|
|
const res = await designsApi.list()
|
|
const loadedDesigns = res.data
|
|
setDesigns(loadedDesigns)
|
|
const targetId = 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, activeDesignId, loadCanvas])
|
|
|
|
// Load canvas on auth (or immediately in standalone mode)
|
|
useEffect(() => {
|
|
if (STANDALONE) {
|
|
try {
|
|
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
|
|
if (saved) {
|
|
const { nodes: savedNodes, edges: savedEdges, theme_id, custom_style } = JSON.parse(saved)
|
|
if (theme_id) setTheme(theme_id)
|
|
if (custom_style) setCustomStyle(custom_style)
|
|
loadCanvas(savedNodes, savedEdges)
|
|
} else {
|
|
loadCanvas(demoNodes, demoEdges)
|
|
}
|
|
} catch {
|
|
loadCanvas(demoNodes, demoEdges)
|
|
}
|
|
return
|
|
}
|
|
if (!isAuthenticated) return
|
|
loadDesignsAndCanvas()
|
|
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change
|
|
|
|
// Reload canvas when active design changes (after initial load)
|
|
const initialLoadDone = useRef(false)
|
|
const prevDesignRef = useRef<string | null>(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
|
|
}
|
|
if (!STANDALONE && isAuthenticated && 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) {
|
|
loadCanvasFromApi(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 {
|
|
loadCanvasFromApi(activeDesignId)
|
|
}
|
|
}
|
|
if (activeDesignId) {
|
|
prevDesignRef.current = activeDesignId
|
|
initialLoadDone.current = true
|
|
}
|
|
}, [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<NodeData>) => {
|
|
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 }
|
|
: { x: 300, y: 300 }
|
|
|
|
const newNode: Node<NodeData> = {
|
|
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<NodeData> = {
|
|
id,
|
|
type: 'groupRect',
|
|
position: { x: 200, y: 200 },
|
|
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<NodeData> = {
|
|
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: { x: 250, y: 250 },
|
|
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<NodeData>) => {
|
|
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 }
|
|
await navigator.clipboard.writeText(md)
|
|
toast.success('Markdown table copied to clipboard')
|
|
}, [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=<id>.
|
|
const handleViewOnly = useCallback(async () => {
|
|
if (STANDALONE) {
|
|
window.open('/view', '_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<HTMLElement>('.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 starting at x=500, y=100
|
|
const COLS = 4
|
|
const SPACING_X = 170
|
|
const SPACING_Y = 100
|
|
zigbeeNodes.forEach((zn, i) => {
|
|
const id = zn.id
|
|
const col = i % COLS
|
|
const row = Math.floor(i / COLS)
|
|
const position = { x: 500 + col * SPACING_X, y: 100 + row * SPACING_Y }
|
|
const newNode: import('@xyflow/react').Node<NodeData> = {
|
|
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 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<EdgeData>) => {
|
|
setEditEdgeId(edge.id)
|
|
}, [])
|
|
|
|
const handleNodeDoubleClick = useCallback((node: Node<NodeData>) => {
|
|
// '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 <LoginPage />
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<ReactFlowProvider>
|
|
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
|
|
<Sidebar
|
|
onAddNode={() => setAddNodeOpen(true)}
|
|
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
|
onAddText={() => setAddTextOpen(true)}
|
|
onScan={() => setScanConfigOpen(true)}
|
|
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
|
onSave={handleSave}
|
|
onOpenSettings={() => setSettingsOpen(true)}
|
|
onOpenHistory={() => setScanHistoryOpen(true)}
|
|
onOpenPending={openPendingModal}
|
|
/>
|
|
<div className="flex flex-col flex-1 min-w-0">
|
|
<Toolbar
|
|
onSave={handleSave}
|
|
onAutoLayout={handleAutoLayout}
|
|
onExport={handleExport}
|
|
onChangeStyle={() => setThemeModalOpen(true)}
|
|
onUndo={undo}
|
|
onRedo={redo}
|
|
onShortcuts={() => setShortcutsOpen(true)}
|
|
onExportMd={handleExportMd}
|
|
onExportYaml={handleExportYaml}
|
|
onImportYaml={handleImportYaml}
|
|
onViewOnly={handleViewOnly}
|
|
/>
|
|
<div className="flex flex-1 min-h-0">
|
|
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
|
<CanvasContainer
|
|
onConnect={handleEdgeConnect}
|
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
|
onNodeDoubleClick={handleNodeDoubleClick}
|
|
onNodeDragStart={snapshotHistory}
|
|
onRequestAddToGroup={setPendingGroupAdd}
|
|
onRequestAddToContainer={setPendingContainerAdd}
|
|
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
|
/>
|
|
</div>
|
|
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<NodeModal
|
|
key={addNodeOpen ? 'add-open' : 'add-closed'}
|
|
open={addNodeOpen}
|
|
onClose={() => 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 }))}
|
|
/>
|
|
|
|
{/* key forces re-mount when editing a different node, resetting form state */}
|
|
<NodeModal
|
|
key={editNodeId ?? 'edit'}
|
|
open={!!editNodeId}
|
|
onClose={() => setEditNodeId(null)}
|
|
onSubmit={handleUpdateNode}
|
|
initial={editNode?.data}
|
|
title="Edit Node"
|
|
parentCandidates={(() => {
|
|
const descendants = new Set<string>()
|
|
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}
|
|
/>
|
|
|
|
<EdgeModal
|
|
key={pendingConnection ? `${pendingConnection.source}-${pendingConnection.sourceHandle}-${pendingConnection.target}-${pendingConnection.targetHandle}` : 'conn-idle'}
|
|
open={!!pendingConnection}
|
|
onClose={() => setPendingConnection(null)}
|
|
onSubmit={handleEdgeConfirm}
|
|
initial={
|
|
pendingConnection?.sourceHandle?.includes('cluster') || pendingConnection?.targetHandle?.includes('cluster')
|
|
? { type: 'cluster' }
|
|
: undefined
|
|
}
|
|
/>
|
|
|
|
<EdgeModal
|
|
key={editEdgeId ?? 'edge-edit'}
|
|
open={!!editEdgeId}
|
|
onClose={() => setEditEdgeId(null)}
|
|
onSubmit={handleEdgeUpdate}
|
|
onDelete={handleEdgeDelete}
|
|
onClearWaypoints={handleClearWaypoints}
|
|
initial={editEdge?.data}
|
|
title="Edit Link"
|
|
/>
|
|
|
|
{!STANDALONE && (
|
|
<ScanConfigModal
|
|
open={scanConfigOpen}
|
|
onClose={() => setScanConfigOpen(false)}
|
|
onScanNow={() => {
|
|
toast.success('Network scan started — check Scan History for results')
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{!STANDALONE && (
|
|
<ZigbeeImportModal
|
|
open={zigbeeImportOpen}
|
|
onClose={() => setZigbeeImportOpen(false)}
|
|
onAddToCanvas={handleZigbeeAddToCanvas}
|
|
onPendingImported={() => {
|
|
toast.success('Zigbee import started — check Scan History for results')
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{!STANDALONE && (
|
|
<ScanHistoryModal
|
|
open={scanHistoryOpen}
|
|
onClose={() => setScanHistoryOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
<GroupRectModal
|
|
open={addGroupRectOpen}
|
|
onClose={() => setAddGroupRectOpen(false)}
|
|
onSubmit={handleAddGroupRect}
|
|
title="Add Zone"
|
|
/>
|
|
|
|
{/* key forces re-mount when editing a different rect */}
|
|
<GroupRectModal
|
|
key={editingGroupRectId ?? 'rect-edit'}
|
|
open={!!editingGroupRectId}
|
|
onClose={() => 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"
|
|
/>
|
|
|
|
<TextModal
|
|
open={addTextOpen}
|
|
onClose={() => setAddTextOpen(false)}
|
|
onSubmit={handleAddText}
|
|
title="Add Text"
|
|
/>
|
|
|
|
<TextModal
|
|
key={editingTextId ?? 'text-edit'}
|
|
open={!!editingTextId}
|
|
onClose={() => 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 */}
|
|
<ThemeModal
|
|
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
|
|
open={themeModalOpen}
|
|
onClose={() => setThemeModalOpen(false)}
|
|
/>
|
|
|
|
<SearchModal
|
|
open={searchOpen}
|
|
onClose={() => setSearchOpen(false)}
|
|
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
|
/>
|
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
|
|
|
<ConfirmAddToGroupModal
|
|
open={!!pendingGroupAdd}
|
|
nodeLabel={pendingGroupAdd ? (nodes.find((n) => 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)}
|
|
/>
|
|
|
|
<ConfirmAddToGroupModal
|
|
open={!!pendingContainerAdd}
|
|
variant="container"
|
|
nodeLabel={pendingContainerAdd ? (nodes.find((n) => 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)}
|
|
/>
|
|
|
|
{!STANDALONE && (
|
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
|
)}
|
|
|
|
<PendingDevicesModal
|
|
open={pendingModalOpen}
|
|
onClose={() => setPendingModalOpen(false)}
|
|
highlightId={pendingHighlightId}
|
|
initialStatus={pendingModalStatus}
|
|
/>
|
|
|
|
<ExportModal
|
|
open={exportModalOpen}
|
|
onClose={() => setExportModalOpen(false)}
|
|
getElement={() => canvasRef.current?.querySelector<HTMLElement>('.react-flow') ?? null}
|
|
/>
|
|
|
|
<Toaster theme="dark" position="bottom-right" />
|
|
</ReactFlowProvider>
|
|
</TooltipProvider>
|
|
)
|
|
}
|