diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6241aef..f1e0d28 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -57,11 +57,13 @@ export default function App() { .then((res) => { const { nodes: apiNodes, edges: apiEdges } = res.data if (apiNodes.length > 0) { - const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number }) => ({ + const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number; parent_id?: string }) => ({ id: n.id, type: n.type, position: { x: n.pos_x, y: n.pos_y }, data: n, + ...(n.parent_id ? { parentId: n.parent_id, extent: 'parent' as const } : {}), + ...(n.type === 'proxmox' ? { width: 300, height: 200 } : {}), })) const rfEdges = apiEdges.map((e: EdgeData & { id: string; source: string; target: string }) => ({ id: e.id, @@ -92,15 +94,24 @@ export default function App() { const handleAddNode = useCallback((data: Partial) => { const id = crypto.randomUUID() + const isProxmox = data.type === 'proxmox' + const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null + // Children position is relative to parent; place near top-left with padding + const position = parentNode + ? { x: 20, y: 50 } + : { x: 300, y: 300 } + const newNode: Node = { id, type: data.type ?? 'generic', - position: { x: 300, y: 300 }, + position, data: { status: 'unknown', services: [], ...data } as NodeData, + ...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}), + ...(isProxmox ? { width: 300, height: 200 } : {}), } addNode(newNode) toast.success(`Added "${data.label}"`) - }, [addNode]) + }, [addNode, nodes]) const handleEditNode = useCallback((id: string) => { setEditNodeId(id) @@ -172,6 +183,7 @@ export default function App() { onClose={() => setAddNodeOpen(false)} onSubmit={handleAddNode} title="Add Node" + proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))} /> {/* key forces re-mount when editing a different node, resetting form state */} @@ -182,6 +194,7 @@ export default function App() { onSubmit={handleUpdateNode} initial={editNode?.data} title="Edit Node" + proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))} /> = { + online: '#39d353', + offline: '#f85149', + pending: '#e3b341', + unknown: '#8b949e', +} + +const GLOW = '#ff6e00' + +export function ProxmoxGroupNode({ data, selected }: NodeProps>) { + const statusColor = STATUS_COLORS[data.status] + const isOnline = data.status === 'online' + + return ( + <> + + + {/* Group border */} +
+ {/* Header bar */} +
+
+ +
+
+ + {data.label} + + {data.ip && ( + {data.ip} + )} +
+ {/* Status dot */} +
+
+ + {/* Inner area — React Flow places children here */} +
+
+ + + + + ) +} diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts index b7fed86..91ebfaa 100644 --- a/frontend/src/components/canvas/nodes/nodeTypes.ts +++ b/frontend/src/components/canvas/nodes/nodeTypes.ts @@ -1,11 +1,12 @@ -import { IspNode, RouterNode, SwitchNode, ServerNode, ProxmoxNode, VmNode, LxcNode, NasNode, IotNode, ApNode, GenericNode } from './index' +import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, GenericNode } from './index' +import { ProxmoxGroupNode } from './ProxmoxGroupNode' export const nodeTypes = { isp: IspNode, router: RouterNode, switch: SwitchNode, server: ServerNode, - proxmox: ProxmoxNode, + proxmox: ProxmoxGroupNode, vm: VmNode, lxc: LxcNode, nas: NasNode, diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index 522caa6..a060b91 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -26,11 +26,14 @@ interface NodeModalProps { onSubmit: (data: Partial) => void initial?: Partial title?: string + proxmoxNodes?: { id: string; label: string }[] } +const CHILD_TYPES: NodeType[] = ['vm', 'lxc'] + // NodeModal is always mounted with a key that changes on open/edit, so useState // initial value is enough — no need for a reset effect. -export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' }: NodeModalProps) { +export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) { const [form, setForm] = useState>({ ...DEFAULT_DATA, ...initial }) const set = (key: keyof NodeData, value: unknown) => @@ -129,6 +132,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' />
+ {/* Parent Proxmox (VM / LXC only) */} + {CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && ( +
+ + +
+ )} + {/* Notes */}
diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 3338a80..f298366 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -55,10 +55,17 @@ export const useCanvasStore = create((set) => ({ setSelectedNode: (id) => set({ selectedNodeId: id }), addNode: (node) => - set((state) => ({ - nodes: [...state.nodes, node], - hasUnsavedChanges: true, - })), + set((state) => { + const enriched = node.data.parent_id + ? { ...node, parentId: node.data.parent_id, extent: 'parent' as const } + : node + // Parents must come before children in the array + const withoutNew = state.nodes.filter((n) => n.id !== node.id) + if (enriched.parentId) { + return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true } + } + return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true } + }), updateNode: (id, data) => set((state) => ({ @@ -78,6 +85,10 @@ export const useCanvasStore = create((set) => ({ markSaved: () => set({ hasUnsavedChanges: false }), - loadCanvas: (nodes, edges) => - set({ nodes, edges, hasUnsavedChanges: false, selectedNodeId: null }), + loadCanvas: (nodes, edges) => { + // React Flow requires parents before children in the array + const parents = nodes.filter((n) => !n.parentId) + const children = nodes.filter((n) => !!n.parentId) + set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null }) + }, })) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5ef1829..f70050b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -39,6 +39,7 @@ export interface NodeData extends Record { last_seen?: string response_time_ms?: number notes?: string + parent_id?: string } export interface EdgeData extends Record { diff --git a/frontend/src/utils/layout.ts b/frontend/src/utils/layout.ts index 0c71f1a..309aa40 100644 --- a/frontend/src/utils/layout.ts +++ b/frontend/src/utils/layout.ts @@ -7,7 +7,8 @@ const NODE_HEIGHT = 52 /** * Apply Dagre hierarchical (top-to-bottom) layout to nodes and edges. - * Returns new nodes with updated positions. + * Child nodes (parentId set) keep their relative position inside the parent — only + * top-level nodes are repositioned by Dagre. */ export function applyDagreLayout( nodes: Node[], @@ -17,22 +18,32 @@ export function applyDagreLayout( g.setDefaultEdgeLabel(() => ({})) g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 80 }) - for (const node of nodes) { - g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }) + const topLevel = nodes.filter((n) => !n.parentId) + + for (const node of topLevel) { + const w = node.type === 'proxmox' ? (node.width ?? 300) : NODE_WIDTH + const h = node.type === 'proxmox' ? (node.height ?? 200) : NODE_HEIGHT + g.setNode(node.id, { width: w, height: h }) } for (const edge of edges) { - g.setEdge(edge.source, edge.target) + // Only add edges between top-level nodes + const srcTop = topLevel.some((n) => n.id === edge.source) + const tgtTop = topLevel.some((n) => n.id === edge.target) + if (srcTop && tgtTop) g.setEdge(edge.source, edge.target) } dagre.layout(g) return nodes.map((node) => { + if (node.parentId) return node // keep children in place const pos = g.node(node.id) + const w = node.type === 'proxmox' ? (node.width ?? 300) : NODE_WIDTH + const h = node.type === 'proxmox' ? (node.height ?? 200) : NODE_HEIGHT return { ...node, position: { - x: pos.x - NODE_WIDTH / 2, - y: pos.y - NODE_HEIGHT / 2, + x: pos.x - w / 2, + y: pos.y - h / 2, }, } })